Release v1.119.0

This commit is contained in:
coso
2026-08-02 04:28:14 +08:00
parent 4362737bd2
commit f3d062b8c4
137 changed files with 7972 additions and 1435 deletions
+19 -25
View File
@@ -1,43 +1,37 @@
## Lime v1.118.0
## Lime v1.119.0
<sub>The Simplified Chinese release notes are the primary version. This English page is a companion for international readers.</sub>
Simplified Chinese release notes are the primary version.
### New Features
- Skill catalogs now refresh automatically when Skills are created, changed, or removed under default roots, or after successful local Lime Skill mutations. Composer always reloads the current App Server `skill/list`, without requiring a manual refresh or app restart.
- Agent plans now use the typed `turn/plan/updated` notification, showing the same checklist during a live Turn and after cold restoration. Failed or malformed snapshots cannot replace the latest valid plan.
- Agent errors now follow a typed retry and terminal chain: retrying, final failure, and the authoritative Turn terminal have distinct states and can be restored from the canonical read model.
- MCP OAuth completion now reaches the Renderer through a direct App Server v2 typed notification, keeping the login window, MCP management state, and event identity aligned.
- MCP server startup now uses typed App Server status notifications, allowing Settings to show `starting`, `ready`, `failed`, and `cancelled` states and refresh server and tool catalogs at terminal states.
- Agent conversations now surface future-protocol unknown Items safely. Live streaming, completion, history recovery, and reload share the same canonical Item instead of silently dropping unknown capabilities.
- Follow-up terminal input in unified exec is attached to the original Command Item and shown as a redacted summary in live and historical conversations; raw stdin never enters notifications, persistence, or the GUI.
### Fixes
- Fixed explicitly configured Provider models on Windows being hidden from the chat model selector when authoritative capability metadata was unavailable. Inferred-only, non-executable models remain fail closed.
- Fixed Electron updater sessions treating equal, older, invalid, or missing versions as installable updates. Only a strictly newer semantic version can enter download and installation.
- Fixed update failures, up-to-date results, and available updates sharing the same message, and aligned download, retry, and restart states across the sidebar, About page, and update notification window.
- Fixed inconsistent presentation of non-executable Provider routes between submit failures and runtime events, and missing global toast hosts on standalone pages.
- Fixed the Provider setup flow showing “Finish adding” before a configuration had been persisted and returning to the wrong model context after a successful save.
- Fixed unknown Items disappearing from the direct timeline after the terminal turn refresh, while preserving the same thread, turn, and item identity during cold recovery.
- Fixed `write_stdin` being projected as a separate Tool Item, stale session bindings after completion, and cross-thread writes not failing explicitly.
- Fixed MCP connection state relying on legacy Desktop events that could drift from the authoritative App Server state.
### Improvements and Refactors
### Improvements and Refactoring
- Consolidated `skills/changed`, `error`, `turn/plan/updated`, and `mcpServer/oauthLogin/completed` into the App Server v2 protocol, schemas, generated client, and Renderer typed event bus without adding a production mock or second event owner.
- Unified Skill catalog cache invalidation in `lime-skills`; default roots use recursive watchers and throttled notifications, while reconnect and remount still perform an active current-catalog read.
- Kept the canonical `update_plan` tool item as the restoration source without generating a duplicate Plan Item, tool card, or decision surface.
- Moved updater version comparison and session transitions into a focused pure owner and tightened the `available -> downloaded -> installing/restarting` state flow.
- Consolidated MCP lifecycle ownership under `mcpServer/startupStatus/updated`, removed the production paths for `mcp:server_started`, `mcp:server_stopped`, and `mcp:server_error`, and added regression guards.
- Unified the typed representation of command terminal interactions and unknown Items across protocol schemas, App Server projections, read models, clients, and the Renderer.
- Restricted unknown Items to upstream type and redacted field names, preventing raw values, event metadata, and generic extension fallbacks from entering the product path.
### Tests and Quality
### Testing and Quality
- Added Rust, Electron, protocol, Renderer, and script regressions for Skills refresh, typed errors, plan updates, MCP OAuth, Provider model catalogs, and updater semantics.
- Expanded the current Agent fixture with typed-error success/failure, live and restored plan updates, Skills runtime refresh, and real Electron/preload/IPC/App Server/read-model identity checks.
- Audited the core user flow across startup, Provider/model selection, Agent chat, stop-and-continue, history restoration, About, and updater states, with zero production mock fallbacks.
- Added a real Electron Gate B fixture for MCP startup notifications, covering successful and failed startup, automatic refresh, IPC/App Server hits, and zero production mock fallback.
- Added a Gate B fixture for live and cold unknown Item recovery, covering safe field display, terminal recovery, identity consistency, and sensitive-value redaction.
- Expanded Codex import continuation, Agent runtime fixture, protocol contract, Rust owner, and Renderer regressions for live, historical, and reload terminal-input summaries.
### Documentation
- Updated architecture and execution sources for App Server v2 notifications, Skill catalog invalidation, MCP OAuth, typed error/plan recovery, and updater/model-catalog behavior.
- Recorded the Windows model-catalog and updater reliability work, core user-flow E2E audit, and the current Refactor v2 V2-05 status.
- Updated the MCP current lifecycle, command boundaries, Refactor v2 event projections, and execution progress with current, excluded, and dead-surface decisions.
### Other
- Bumped the root app, CLI npm package, Rust workspace, and lockfile versions to `1.118.0`.
- The Windows Squirrel packaged Gate B for N-1 discovery, download, restart, and installation still requires a real Windows host; macOS Electron evidence does not replace that platform check.
- Bumped the root app, CLI npm package, Rust workspace, and lockfile versions to `1.119.0`.
**Full changes**: `v1.117.0` -> `v1.118.0`
**Full changes**: `v1.118.0` -> `v1.119.0`
+16 -22
View File
@@ -1,41 +1,35 @@
## Lime v1.118.0
## Lime v1.119.0
### 新功能
- Skill 目录现在会在默认目录新增、修改或删除 Skill,以及 Lime 本地 Skill 管理成功后自动刷新;Composer 始终重新读取 App Server 的 current `skill/list`,无需手动刷新或重启应用
- Agent 执行计划接入 typed `turn/plan/updated` 通知,实时对话与冷恢复会展示同一 checklist;失败或畸形的计划快照不会覆盖最近一次有效状态
- Agent 错误升级为 typed retry/terminal 链路:重试中、最终失败与权威 Turn terminal 各自有明确状态,并可从 canonical read model 恢复
- MCP OAuth 登录完成态改由 App Server v2 typed notification 直接投影到 Renderer,统一登录窗口、MCP 管理状态与事件 identity。
- MCP Server 启动过程接入 App Server typed 状态通知,设置页可实时展示 `starting``ready``failed``cancelled`,并在终态自动刷新服务器与工具目录
- Agent 对话现在会安全展示未来协议产生的 unknown Item;实时流、完成态、历史恢复与重新加载共享同一 canonical Item,不再静默丢失未知能力记录
- unified exec 的后续终端输入会回挂原始 Command Item,并以脱敏摘要呈现在实时和历史对话中;原始 stdin 不进入通知、持久化或 GUI
### 修复
- 修复 Windows 用户已显式配置的 Provider 模型因缺少权威 capability 来源而不出现在聊天模型选择器中的问题;仅靠启发式推断且不可执行的模型继续 fail closed
- 修复 Electron updater 将同版本、旧版本、无效版本或缺失版本误报为可安装更新的问题;只有严格更高的语义版本进入下载和安装流程
- 修复更新失败、已是最新和发现新版本共用提示的问题,并统一侧边栏、关于页与更新通知页的下载、重试和重启安装状态
- 修复 Provider 路由不可执行错误在发送入口和 runtime 事件链中出现不一致提示,以及部分独立页面缺少全局 toast 宿主的问题。
- 修复 Provider 添加流程在尚未保存配置时显示“完成添加”,并在已保存后正确返回可用模型上下文。
- 修复 Agent 回合完成刷新后 unknown Item 从 direct timeline 消失的问题,并保留同一 thread、turn、item identity 进行冷恢复
- 修复 `write_stdin` 被投影为独立 Tool Item、会话结束后绑定未及时释放,以及跨 Thread 写入未明确失败的问题
- 修复 MCP 启动状态依赖旧 Desktop events 导致连接态与 App Server 权威状态可能不同步的问题
### 优化与重构
- `skills/changed``error``turn/plan/updated` `mcpServer/oauthLogin/completed` 收敛到 App Server v2 protocol、schema、generated client 和 Renderer typed event bus,不新增生产 mock 或第二事件 owner
- Skill catalog cache 统一由 `lime-skills` 失效;默认 Skill roots 使用递归 watcher 与节流通知,重连和 remount 仍主动读取 current catalog
- canonical `update_plan` 工具项继续作为恢复事实源,但不再生成重复 Plan Item、工具卡或决策面板
- updater 的版本比较与会话状态拆到独立纯 owner,收紧 `available -> downloaded -> installing/restarting` 状态转换。
- 收敛 MCP lifecycle 到 `mcpServer/startupStatus/updated` 单一 owner,删除旧 `mcp:server_started``mcp:server_stopped``mcp:server_error` 生产路径并补回流守卫
- 统一 command terminal interaction、unknown Item 在协议、schema、App Server projection、read model、客户端与 Renderer 中的 typed 表达
- unknown Item 仅暴露上游类型和脱敏字段名,禁止 raw value、event metadata 与通用 extension fallback 进入产品链
### 测试与质量
- 新增 Skills 自动刷新、typed error、执行计划、MCP OAuth、Provider 模型目录与 updater 语义的 Rust、Electron、协议、Renderer 和脚本回归
- 扩展 current Agent fixture,覆盖 typed error 成功/失败、计划实时更新与冷恢复、Skills runtime 自动刷新,以及真实 Electron/preload/IPC/App Server/read model identity
- 核心用户流程审计覆盖启动、Provider/模型选择、Agent 对话、停止后继续、历史恢复、关于页与更新状态;生产 mock fallback 命中为零
- 新增 MCP startup notification 真实 Electron Gate B,覆盖成功与失败状态、自动刷新、IPC/App Server 命中及零生产 mock fallback
- 新增 unknown Item live/cold recovery Gate B,覆盖安全字段展示、终态恢复、身份一致性与敏感值不泄漏
- 扩展 Codex import continuation、Agent runtime fixture、协议合同、Rust owner 与 Renderer 回归,覆盖终端输入摘要的实时、历史和 reload 路径
### 文档
- 更新 App Server v2 notification、Skills catalog invalidation、MCP OAuth、typed error/plan recovery 与 updater/模型目录的架构和执行事实源
- 记录 Windows 模型目录与更新可靠性、核心用户主流程 E2E 审计,以及 Refactor v2 V2-05 当前完成情况。
- 更新 MCP current lifecycle、命令边界、Refactor v2 event projection 与执行进度,记录 current、排除范围和 dead surface 裁决
### 其他
- 将根应用、CLI npm 包、Rust workspace 与锁文件版本统一提升到 `1.118.0`
- Windows Squirrel 从 N-1 版本发现、下载、重启安装的 packaged Gate B 仍需 Windows 实机验收;macOS Electron 证据不替代该平台验证。
- 将根应用、CLI npm 包、Rust workspace 与锁文件版本统一提升到 `1.119.0`
**完整变更**: `v1.117.0` -> `v1.118.0`
**完整变更**: `v1.118.0` -> `v1.119.0`
+1 -1
View File
@@ -59,7 +59,7 @@ MCP 管理、发现和调用只允许走:
`src/lib/api/mcp.ts -> AppServerClient.request(...) -> app_server_handle_json_lines -> App Server JSON-RPC -> lime-rs/crates/mcp`
current method 为 `mcpServer/list``mcpServerStatus/list``mcpServer/create``mcpServer/update``mcpServer/delete``mcpServer/enabled/set``mcpServer/importFromApp``mcpServer/syncAllToLive``mcpServer/oauth/login``mcpServer/oauthLogin/completed``mcpServer/start``mcpServer/stop``mcpTool/list``mcpTool/listForContext``mcpTool/search``mcpTool/call``mcpTool/callWithCaller``mcpPrompt/list``mcpPrompt/get``mcpResource/list``mcpResource/read``mcpResource/subscribe``mcpResource/unsubscribe`。OAuth 完成态只允许由 App Server v2 typed notification `mcpServer/oauthLogin/completed` 投影给 Renderer,不再经过 Desktop event。事件 `mcp:resources_updated``mcp:resource_updated` 必须经真实 MCP manager / Desktop Host event bridge 投影;浏览器模式不得静默退回 mock event fallback。
current method 为 `mcpServer/list``mcpServerStatus/list``mcpServer/create``mcpServer/update``mcpServer/delete``mcpServer/enabled/set``mcpServer/importFromApp``mcpServer/syncAllToLive``mcpServer/oauth/login``mcpServer/oauthLogin/completed``mcpServer/startupStatus/updated``mcpServer/start``mcpServer/stop``mcpTool/list``mcpTool/listForContext``mcpTool/search``mcpTool/call``mcpTool/callWithCaller``mcpPrompt/list``mcpPrompt/get``mcpResource/list``mcpResource/read``mcpResource/subscribe``mcpResource/unsubscribe`。OAuth 完成态只允许由 App Server v2 typed notification `mcpServer/oauthLogin/completed` 投影给 RendererMCP startup lifecycle 只允许由 `mcpServer/startupStatus/updated` 投影连接态并触发终态刷新。旧 `mcp:oauth_completed``mcp:server_started``mcp:server_stopped``mcp:server_error` 均为 `dead / deleted / forbidden-to-restore`。事件 `mcp:resources_updated``mcp:resource_updated` 必须经真实 MCP manager / Desktop Host event bridge 投影;浏览器模式不得静默退回 mock event fallback。
live evidence 仅通过 `smoke:mcp-current -- --allow-live-provider` 显式开启,且需要 `LIME_MCP_LIVE_SERVER_URL`。该 URL 不得包含 username、password、query 或 hash;认证只能引用环境变量名,不允许 inline secret。`network-invoke.json` 仅可记录脱敏的 host、环境变量名、header 名、范围和工具/资源摘要。
+10 -1
View File
@@ -20,7 +20,7 @@ src/lib/api/
src/hooks/
├── useMcp.ts # MCP runtime 状态与事件刷新
└── useMcpEvents.ts # MCP Desktop events 与 App Server typed notification bridge
└── useMcpEvents.ts # MCP resource Desktop events 与 App Server typed notification bridge
src/components/mcp/
├── McpPage.tsx # 设置页入口
@@ -55,6 +55,7 @@ Current MCP method 固定为:
- `mcpServer/syncAllToLive`
- `mcpServer/oauth/login`
- `mcpServer/oauthLogin/completed`
- `mcpServer/startupStatus/updated`
- `mcpServer/start`
- `mcpServer/stop`
- `mcpTool/list`
@@ -71,6 +72,13 @@ Current MCP method 固定为:
旧 Desktop facade 已归类为 `dead / retired guard-only`,包括 `get_mcp_servers``mcp_list_*``mcp_call_tool*``mcp_get_prompt``mcp_read_resource``mcp_start_server``mcp_stop_server``add_mcp_server``update_mcp_server``delete_mcp_server``toggle_mcp_server``import_mcp_from_app``sync_all_mcp_to_live`。这些名字只能出现在负向测试、contract forbidden snippet、smoke legacy 黑名单或历史 evidence 中。
## Lifecycle notification
- `mcpServer/start` 在调用 RuntimeCore 前发布 `mcpServer/startupStatus/updated { threadId: null, name, status: "starting", error: null, failureReason: null }`
- 启动成功发布 `ready`;启动失败先发布带 typed error 的 `failed`,再保留原 JSON-RPC error。`cancelled``reauthenticationRequired` 保留为 Codex 对齐的严格协议值,当前 app-scoped start producer 不伪造它们。
- Renderer 必须同步订阅 App Server typed event bus`starting` 投影 server connection phase`ready` / `cancelled` / `failed` 刷新 `mcpServerStatus/list``mcpTool/list``failed` 显示连接错误。
- `mcp:server_started``mcp:server_stopped``mcp:server_error``mcp:oauth_completed` 均为 `dead / deleted / forbidden-to-restore``mcp:tools_updated``mcp:resources_updated``mcp:resource_updated` 仍由各自 current owner 承接,不得与 startup notification 混成第二生命周期 owner。
## Server-Originated Elicitation
`mcpServer/elicitation/request` 是 App Server `serverRequest`,不是 Renderer `AppServerRequestMethod`。公开 request 只包含必填非空 `threadId`、可空 `turnId`、必填非空 `serverName` 和 typed `mode: "form"``sessionId``parentToolCallId`、私有 scope token 和 raw MCP request id 都禁止跨 JSON-RPC 边界。
@@ -121,6 +129,7 @@ npm run smoke:mcp-current
npm run smoke:mcp-current -- --allow-write-fixture
npm run smoke:mcp-current -- --allow-oauth-fixture
npm run smoke:mcp-oauth-notification-electron-fixture
npm run smoke:mcp-startup-notification-electron-fixture
npm run smoke:mcp-current -- --allow-live-provider
```
@@ -413,6 +413,8 @@
- 2026-07-31OAuth completion 已从 `mcp:oauth_completed` Desktop event 迁到 App Server `mcpServer/oauthLogin/completed` typed notification。Rust MCP 只返回 completion handleApp Server 投影成功/失败;Renderer 在任何异步 Desktop listener 前同步订阅 typed event bus,并自动刷新 status/tools。旧事件字符串受 contract guard 禁止回流 `electron``lime-rs``packages``src`
- 2026-07-31`npm run smoke:mcp-oauth-notification-electron-fixture` 专用 Gate B 通过。证据 `.lime/qc/mcp-oauth-notification/mcp-oauth-notification-fixture-summary.json``ok=true`:真实 Electron/preload/IPC、`app_server_handle_json_lines`、App Server event drain、本地 OAuth provider callback 与 GUI 状态/toast 全链命中;自动刷新 `mcpServer/oauth/login``mcpServerStatus/list``mcpTool/list``mockFallbackHitCount=0``failedInvokeCount=0`、console/page/invoke errors 为 0。
- 2026-07-31MCP startup lifecycle 已从 `mcp:server_started``mcp:server_stopped``mcp:server_error` Desktop events 迁到 App Server `mcpServer/startupStatus/updated` typed notification。`mcpServer/start` 在 runtime 前发布 `starting`,成功发布 `ready`,失败发布带 error 的 `failed` 后保留原 JSON-RPC errorRenderer 同步订阅 typed event bus,投影连接态并在终态自动刷新 status/tools。四条旧 MCP lifecycle event(含 OAuth)均受 contract guard 禁止回流生产路径。
- 2026-07-31`npm run smoke:mcp-startup-notification-electron-fixture` 专用 Gate B 通过。证据 `.lime/qc/mcp-startup-notification/mcp-startup-notification-fixture-summary.json``ok=true`:真实 Electron/preload/IPC、`app_server_handle_json_lines`、App Server event drain、runtime stdio MCP server 与 Settings GUI 全链命中;`starting -> ready``starting -> failed` 均可见,自动刷新 `mcpServerStatus/list``mcpTool/list``electronIpcHitCount=11``mockFallbackHitCount=0``failedInvokeCount=0`、console/page/invoke errors 为 0。
## 当前缺口
@@ -2100,6 +2100,118 @@ Gate Bnpm run smoke:mcp-oauth-notification-electron-fixture 通过,proofLev
OPEN_REF:下一刀回到 EVENT-PROJECTIONS.md,继续审计具备真实 producer/consumer 的 planned notification、host capability 或 recoveryguardianWarning 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 95%,仍非 release-ready。
```
### 9.76 V2-05 MCP startup status typed notification closure
```text
状态:completed2026-07-31);只关闭 mcpServer/startupStatus/updated 垂直切片,V2-05 与 v2 总体仍为 in-progress,不是 release-ready。
主目标:把 MCP server startup lifecycle 从旧 Desktop events 收敛到 App Server v2 typed notification,使 starting/ready/failed、Renderer 连接态、终态自动刷新与失败可见错误走唯一产品链;不保留 mcp:server_started/stopped/error compat,不新增第二 MCP 状态 owner或生产 mock fallback。
本轮窄写集:lime-mcp lifecycle legacy event 删除;App Server mcpServer/start notification producerapp-server-protocol v2 notification/schema/generated clientapp-server-client strict decoderRenderer useMcp typed event subscription 与回归;MCP startup 专用 Electron fixture;共享 MCP fixture delaycontract guardMCP/V2 文档与本执行计划。保留共享工作树其他 V2-05 notification、host capability、recovery 与版本改动,不做无关重写。
退出条件:mcpServer/start 在 runtime 调用前发 starting,成功发 ready,失败发 failed 后保留原 JSON-RPC errorthreadId/error/failureReason 必填可空、name 必填非空、status 为 starting|ready|failed|cancelleddecoder 对缺字段和未知字段 fail closedRenderer 同步订阅 typed event busstarting 投影连接态,终态自动刷新 status/toolsfailed 显示 typed error;生产路径无 mcp:server_started/stopped/errorRust/TS/contracts/governance/scripts 通过;真实 Electron Gate B 证明 preload/IPC、app_server_handle_json_lines、App Server event drain、runtime MCP server、GUI 状态与零 mock fallback。
实现事实:App Server processor 在 mcpServer/start 边界发布 ServerNotification::McpServerStatusUpdatedprotocol、schema、generated TypeScript 与 app-server-client strict decoder 使用同一五字段 payload。Renderer setupMcpEventListeners 在串行 Desktop safeListen 前同步注册 App Server notificationstarting 更新 connection phaseready/cancelled/failed 复位 phase 并刷新 mcpServerStatus/list 与 mcpTool/listfailed 同步 server error 与 global error。lime-mcp 删除旧 start/stop/error payload、emitter 与生命周期调用;资源和工具更新事件保持 current。
验证:lime-mcp 151/151、App Server protocol MCP 6/6、App Server MCP processor 6/6、useMcp 10/10、app-server-client 102/102、MCP workspace fixture 3/3。npm run test:contracts 通过,包含 850 schema definitions、842 generated protocol types、0 generation failures、292 app-server-client checks,以及 command/scripts/docs guardsnpm run test:rust:related、npm run governance:legacy-report、npm run governance:scripts 与 npm run verify:gui-smoke 均通过。
Gate Bnpm run smoke:mcp-startup-notification-electron-fixture 通过,proofLevel=Gate B、ok=true。真实 Electron/preload/IPC、app_server_handle_json_lines、App Server event drain、runtime stdio MCP server 与 Renderer Settings GUI 全部命中;startingVisible、readyVisible、failedVisible 均为 true,通知序列分别为 starting->ready 与 starting->failed,自动刷新 methods 为 mcpServerStatus/list、mcpTool/list。appServerHandleJsonLinesHitCount=4、appServerDrainEventsHitCount=7、electronIpcHitCount=11、mockFallbackHitCount=0、failedInvokeCount=0legacy MCP commands 与 console/page/invoke errors 为 0。证据:.lime/qc/mcp-startup-notification/mcp-startup-notification-fixture-summary.json 与同目录 PNG。
架构确认:本切片没有改变 public owner、唯一产品链或依赖方向,只把既有 MCP startup lifecycle 从 legacy Desktop events 迁到 App Server v2 typed notification,并补齐 Renderer/Gate B;无需改写 internal/aiprompts/architecture.md。责任开发者确认:root2026-07-31。
治理分类:typed protocol/schema、App Server startup producer、strict client decoder、Renderer typed event bus、GUI 连接态与专用 Gate B 为 current;无 compatmcp:server_started、mcp:server_stopped、mcp:server_error 为 dead/deleted/forbidden-to-restore。
OPEN_REF:下一刀回到 EVENT-PROJECTIONS.md,继续审计具备真实 producer/consumer 的 planned notification、host capability 或 recoveryguardianWarning 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 96%,仍非 release-ready。
```
### 9.77 V2-05 unknown Item fail-visible Gate B closure
```text
状态:completed2026-07-31);unknown canonical Item 的真实 Electron fail-visible 与恢复证据已关闭;不改变 Codex 72 notification 裁决,也不新增 unknown Item 业务 fallback。V2-05 与 v2 总体仍为 in-progress / 非 release-ready。
主目标:通过受控 external backend 产生一个未来 Item type,使真实 Electron/preload/IPC、app_server_handle_json_lines、App Server runtime/read model 与 direct TurnTimeline 使用同一 thread/turn/item identityGUI 只显示 upstream type 与脱敏字段名,禁止显示原始字段值、secret、raw payload 或 unknown_item 内部类型名。
实现事实:App Server canonical owner 新增 typed Unknown payload,只保留 upstream type 与排序、限量、敏感名脱敏后的字段名;raw values、metadata 与 Extension escape hatch 均不得承接未知协议数据。v2 ThreadItem、thread/read 与 Agent read model 使用同一 typed identity。Renderer 终态刷新此前只更新 threadRead、没有把 canonical thread_items 合入 direct timeline,导致 live Unknown 在完成后消失;现由 applyReadModelSnapshot 合并 read-model items 到 threadItems,完成态历史摘要持续显示同一安全诊断。
测试与守卫:定向 Vitest 66/66unknown fixture/合并/UI 回归 23/23typecheck、test:contracts、governance:scripts、governance:legacy-report 与 git diff --check 通过。npm run smoke:agent-runtime-current-fixture 聚合门禁完整通过且 liveProviderUsed=falsenpm run verify:gui-smoke 21/21 assertions 通过,证据为 .lime/qc/project-gates/standalone-shell-01-20260731194839-48452/shell-01-electron-smoke/summary.json。npm run test:rust:related 按协议反向依赖扩圈并以退出码 0 完成,关键结果包括 agent-protocol 41/41、app-server 1659/1659、app-server-protocol 99/99、lime-mcp 151/151。
Gate Bnpm run smoke:unknown-item-recovery-electron-fixture 通过,proofLevel=Gate B controlled fixture、ok=true。真实 Electron/preload/IPC、app_server_handle_json_lines、App Server external runtime/read model 与 direct TurnTimeline 使用同一 thread/turn/item identityitem.started -> item.completed -> turn.completed 稳定。GUI 仅有 1 个 UnknownfutureCapability 与 [redacted]/label/opaquePayload/status 可见,raw values、secret 与 unknown_item 内部名不可见;thread/read 恢复同一 completed Itemconsole/page/invoke errors 与生产 mock fallback 均为 0。证据:.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-unknown-item-regression-summary.json。
架构确认:本切片没有改变唯一产品链、public owner 或依赖方向,只补齐既有 canonical Item -> v2 read model -> direct TurnTimeline 的 typed 安全投影和终态恢复;无需改写 internal/aiprompts/architecture.md。责任开发者确认:root2026-07-31。
治理分类:typed Unknown protocol/canonical payload、App Server materializer/read model、Renderer terminal merge、GUI fail-visible 与专项 Gate B 为 current;无 compatunknown Item null drop、raw payload/metadata 透传、Extension fallback 与生产 mock fallback 为 dead / deleted / forbidden-to-restore。
OPEN_REF:下一刀回到 EVENT-PROJECTIONS.md,继续审计具备真实 producer/consumer 的 planned notification、host capability 或 recoveryguardianWarning 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 97%,仍非 release-ready。
```
### 9.78 V2-05 product-scope-excluded notification classification
```text
状态:completed2026-07-31);本刀只收口三项 upstream notification 的产品范围裁决,不把 exclusion 误报为 current 实现。V2-05 与 v2 总体仍为 in-progress / 非 release-ready。
主目标:将 Codex `turn/diff/updated``process/outputDelta``process/exited` 从 Lime planned notification surface 移出。Codex turn diff payload 是整段 raw unified diff,而 Lime current 事实源是 canonical structured FileChangeCodex process 通知属于标记为 experimental 的 standalone `process/spawn`,且在 app server host 上无 Codex sandbox。Lime 当前没有该 upstream notification 的产品 producer/consumer,不能新增 Renderer 面板、protocol facade、compat wrapper 或生产 mock 来制造支持;未来若 standalone process 成为产品需求,必须重新建立独立 typed owner,不得复用本次 excluded 事件。
实现事实:EVENT-PROJECTIONS 与 render projection coverage 将三项标为 `product-scope-excluded`,仅保留 `diagnostics` outlet`isAppServerV2NotificationMethod` 不接入三项,notification drift recorder 仍识别 upstream method 但只记录方法、路径和脱敏 field names`projectAppServerNotificationDriftPayload` 不生成用户级 warning。canonical FileChange / `item/fileChange/patchUpdated` 继续是 Lime diff owner`item/commandExecution/*` 与既有 execution-process 内部投影继续承接产品命令生命周期。
守卫与验证:coverage boundary 断言三项仅 diagnostics 且不在 current projectordrift test 断言 raw diff、process output、process exit 不生成 Renderer payload,且不保留原始值。同步更新 V2 分类表、coverage fixture 与本执行计划;不改 Codex upstream inventory、schema hash、Lime manifest 或 v1 method count。
治理分类:canonical FileChange 与 item/commandExecution lifecycle 为 `current`;无 `compat`;三项 upstream notification 为 `product-scope-excluded`raw unified diff renderer、standalone process panel、process notification facade 与生产 mock fallback 为 `dead / deleted / forbidden-to-restore`。本刀没有改变唯一产品链、public owner 或依赖方向,无需改写 `internal/aiprompts/architecture.md`。责任开发者确认:root2026-07-31。
OPEN_REF:继续审计 EVENT-PROJECTIONS 中仍有真实 producer/consumer 的 planned notification、host capability 或 recovery`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。三项 excluded surface 不再作为 planned 实现目标,v2 总体完成度保守估算约 97%,仍非 release-ready。
```
### 9.79 V2-05 remaining planned notification producer audit
```text
状态:审计完成(2026-07-31);本轮没有把缺少真实 producer/consumer 的通知错误升格为 current,也没有新增协议或 compat。V2-05 与 v2 总体仍为 in-progress / 非 release-ready。
审计范围:`app/list/updated``thread/environment/connected``thread/environment/disconnected``fs/changed``fuzzyFileSearch/sessionUpdated``fuzzyFileSearch/sessionCompleted``guardianWarning``thread/realtime/*``windows/worldWritableWarning``windowsSandbox/setupCompleted``hook/started` / `hook/completed`
事实:这些 method 在 Lime current 代码中没有 App Server typed producer、manifest contract、Renderer consumer 和 Gate B 证据;工作树中唯一相近的 Hook producer 是 `workflow.hook.started/completed`,由插件宿主追加到 `workflow-events.jsonl` 的 audit-only 旁路,不进入普通 runtime event stream、worker evidence 或 Article Workspace。Realtime projection 仅提供 `not_current` 安全守卫,明确要求 current owner 后才能进 Thread/Turn/Item;当前没有该 owner。其余通知只有 upstream drift recorder 诊断命中。
治理裁决:上述仍属于 Lime 产品候选的通知继续保持 `planned`,因为 Apps、environment、filesystem/search、Guardian、Realtime、Windows sandbox 与 Hook 仍需独立产品 owner 决策;当前没有 `compat``workflow.hook.*` audit-only 事件、realtime guard-only projection 与 drift recorder 为诊断/旁路,不得冒充 Codex notification current 实现;没有证据支持将它们标为 current 或恢复第二事件流。
验证:`node --test packages/agent-runtime-projection/tests/realtimeConversationItem.test.mjs` 4/4`rg` producer/consumer 盘点确认除 drift recorder 外无匹配 current producer;前一刀的 boundary/drift 8/8、contracts、governance report 与 typecheck 均通过。未运行 Electron Gate B,因为当前没有可执行 producer/consumer 垂直链;这不是测试失败,而是本轮明确记录的 blocker。
OPEN_REF:下一刀只从上述 planned 集合中选择能同时提供 typed producer、真实 consumer、持久化/恢复语义与风险匹配 Gate B 的垂直切片;在 owner 出现前,不扩展 protocol、Renderer 或 fixture。v2 总体完成度保守估算仍为 97%,仍非 release-ready。
```
### 9.80 V2-05 unified exec terminal interaction closure
```text
状态:completed2026-08-01);本刀只关闭 unified exec `write_stdin` 的 canonical lifecycle、typed notification、cold read 与真实 GUI recoveryV2-05 与 v2 总体仍为 in-progress / 非 release-ready。
主目标:让 `write_stdin` 归属其原始 `exec_command` Command Item,不创建第二个工具项;只暴露脱敏摘要 `sent N chars`,通过 App Server typed `item/commandExecution/terminalInteraction`、canonical `terminalInteractions` 和 GUI timeline 以同一 thread/turn/item identity 显示,reload 后仍可从 canonical read 恢复。禁止写入原始 stdin、恢复 retired shell tool 或生产 mock fallback。
实现事实:tool-runtime 将 terminal interaction 绑定原始 exec call identityagent lifecycle emitter 忽略 `write_stdin` 的 Started 事件并在终态后释放绑定;App Server 只投影 active Command Item 的 typed interaction,并把脱敏数据写入 canonical CommandExecution metadata。Renderer live timeline 与 historical direct timeline 都消费同一 `terminalInteractions` 字段。Electron fixture 在导入续聊前走 current `thread/resume` 建立 thread subscription,再经真实 `app_server_drain_events` 等待 typed notification;这修正了 fixture 仅读取同步 JSON-RPC response、遗漏后台通知的观察缺口,不改变生产协议。
验证:tool-runtime 定向回归覆盖跨 thread `write_stdin` 归属与原 `exec_command` 完成;Renderer 定向测试 7 files / 159 tests、`npm run build:renderer:electron` 与 fixture guard 6/6 通过;`git diff --check` 通过。`npm run smoke:codex-import-continuation-electron-fixture -- --timeout-ms 240000` 通过:本地 provider 共 6 次请求,导入和普通会话的 completed Command Item 同构,分别保存 `sent 9 chars`,导入侧 terminal interaction typed notification count=1;普通会话实时 GUI 与 reload GUI 均显示摘要、隐藏原始 stdin、各有一个 completed command rowconsole errors 为 0。通用 `npm run verify:gui-smoke` 也以退出码 0 通过,真实 Electron renderer、App Server sidecar、Claw shell reload 与 memory settings 均就绪;证据为 `.lime/qc/project-gates/standalone-shell-01-20260801154238-47878/shell-01-electron-smoke/summary.json`
Gate B:证据 `.lime/qc/gui-evidence/codex-import-continuation-fixture/codex-import-continuation-fixture-summary.json``ok=true`。真实 Electron/preload/IPC、`app_server_handle_json_lines``app_server_drain_events`、App Server runtime/read model 和 GUI 都已命中;`rendererTurnStart.transport=electron-ipc``commandShapesIsomorphic=true`,导入与普通会话使用独立 canonical identity,生产 mock fallback 为零。
架构确认:本切片未改变唯一产品链、public owner 或依赖方向;只补齐既有 `tool-runtime -> agent -> App Server -> Thread/Turn/Item -> GUI` 的 lifecycle 投影、冷恢复与 Gate B 观察,故无需修改 `internal/aiprompts/architecture.md`。责任开发者确认:root2026-08-01。
治理分类:unified exec、canonical CommandExecution `terminalInteractions`、typed terminal interaction notification、Renderer live/historical projection 与 Electron Gate B fixture 为 current;无 compat;原始 stdin、独立 write_stdin Tool Item、retired Bash/PowerShell tools 与生产 mock fallback 为 dead / forbidden-to-restore。
OPEN_REF:继续审计 EVENT-PROJECTIONS 中有完整 producer/consumer 的 notification/recovery 链;不能因本刀 fixture 通过而把无 producer 的 planned surface 升格为 current。v2 总体完成度保守估算仍为 97%,仍非 release-ready。
```
### 9.81 V2-05 remaining planned producer/consumer audit
```text
状态:审计完成(2026-08-02);本轮没有发现可安全升格为 current 的 planned notification,也没有新增 protocol facade、compat 或生产 mock。V2-05 与 v2 总体仍为 in-progress / 非 release-ready。
审计范围:`item/autoApprovalReview/started``item/autoApprovalReview/completed``app/list/updated``thread/environment/connected``thread/environment/disconnected``fs/changed``fuzzyFileSearch/sessionUpdated``fuzzyFileSearch/sessionCompleted``guardianWarning``thread/realtime/*``windows/worldWritableWarning``windowsSandbox/setupCompleted``hook/started` / `hook/completed`
事实:当前代码中这些 Codex method 仍没有同时具备 App Server typed producer、current manifest/schema contract、Renderer consumer、canonical persistence/recovery 与风险匹配 Gate B。Guardian 现有能力是 `thread/approveGuardianDeniedAction` 与 evidence/projection 读写,不是 `item/autoApprovalReview/*` producer;插件侧 `workflow.hook.started/completed` 只是 `workflow-events.jsonl` audit-only 旁路,不进入普通 runtime event stream、worker evidence 或 Article Workspace。Realtime 只有 `packages/agent-runtime-projection``not_current` guard,明确禁止把 transcript/audio/raw payload 接入 Thread/Turn/ItemApps、environment、filesystem/search 与 Windows sandbox 只有 drift recorder 或静态 UI 资料,没有 current App Server producer。
治理裁决:上述仍属于 Lime 产品候选的通知继续保持 `planned`;无 `compat``workflow.hook.*` audit-only、Guardian denied-action/evidence、realtime guard-only projection、旧 scene/app catalog 与 notification drift recorder 都不能冒充 Codex notification current owner,也不能恢复第二事件流、第二 catalog 或 renderer mock fallback。
验证:`rg` producer/consumer/owner 盘点确认除 drift recorder、guard-only projection、插件 audit-only hook 与 Guardian denied-action/evidence 外无匹配 current producer;现有 `packages/agent-runtime-projection/tests/realtimeConversationItem.test.mjs` 4/4 继续通过。未运行 Electron Gate B,因为本轮没有可执行的 typed producer/consumer 垂直链;这是明确的 owner blocker,不是测试失败。
下一刀:只有当某一 planned 项同时提供唯一 current owner、typed producer、真实 consumer、持久化/恢复语义与 Gate B 场景时才继续实现;在此之前保持 fail closed,v2 总体完成度保守估算仍为 97%,仍非 release-ready。
```
## 10. 完成定义
v2 实施不能因为“几个进程都有改动”而标记完成。每个 slice 必须同时具备:
@@ -0,0 +1,55 @@
# Lime v1.119.0 发布执行计划
状态:in-progress
日期:2026-08-01
目标版本:`1.119.0`
目标 tag`v1.119.0`
## 主目标
`v1.118.0` 后当前工作树中的 MCP startup status typed notification、unknown Item 安全投影与恢复、unified exec terminal interaction 脱敏持久化和对应协议、GUI、测试、文档作为同一个 release candidate,完成版本事实源、双语 release notes、发布门禁、release commit、tag、`main`/tag 推送与远端复核。
## 当前阶段与下一刀
- 当前阶段:发布门禁已完成,候选范围和 staged 边界待最终复核;等待 Git 写操作确认。
- 下一刀:在不修改真实 index 的前提下计算完整候选 staged 摘要,请求一次 commit/tag/push 明确确认;确认后连续完成 Git 写入、远端推送和 tag 复核。
## Release Candidate
- 基线:`v1.118.0`
- 初始盘点:123 个 tracked diff 文件、8 个未跟踪文件,tracked diff 为 6,883 行新增、1,347 行删除。
- `release metadata`:根 `package.json``packages/lime-cli-npm/package.json``lime-rs/Cargo.toml``lime-rs/Cargo.lock`、双语 release notes 与本计划。
- `candidate changes`:初始工作树全部产品、协议、schema、生成客户端、GUI、文档、测试与脚本改动;重点包括 MCP startup status、unknown Item live/cold recovery 与 unified exec terminal interaction。
- `excluded changes`:无。初始盘点未发现缓存、构建产物、凭证、个人临时文件或与候选主题无关的文件。
- 并行避让:除 release metadata 与本计划外,其余候选文件只读验证,不覆盖已有业务改动;若门禁暴露 release blocker,只做可定位、可验证的最小修复并在本计划记录。
## 写集与退出条件
- 版本事实源同步为 `1.119.0`release notes 只保留当前版本单页。
- 必跑 `npm run verify:app-version``npm run typecheck``npm run test:contracts``npm run test:rust:changed``npm run smoke:agent-runtime-current-fixture``npm run verify:gui-smoke``git diff --check`
- MCP startup、unknown Item 与 terminal interaction 已有专项 Gate B 证据;发布门禁若发现证据过期或相关回归,再定向复跑对应 fixture。
- 本候选未改变 public owner、唯一产品链或依赖方向,无需修改 `internal/aiprompts/architecture.md`;责任开发者将在发布收口时确认。
- Git 写操作必须获得明确确认;确认前不执行 `git add``git commit``git tag``git push`
## 验证记录
- `npm run verify:app-version`:通过,所有版本事实源为 `1.119.0`
- `npm run typecheck`:通过。
- `npm run test:contracts`:通过;844 generated protocol types、0 generation failures、292 app-server-client checks。
- `npm run test:rust:changed`:首轮暴露协议稳定名称断言遗漏 `item/commandExecution/terminalInteraction`;已在 `lime-rs/crates/app-server-protocol/src/protocol/v2/tests.rs` 做最小修复并重跑通过。关键结果:App Server 1662/1662、protocol 99/99、MCP 151/151、tool-runtime 313/313。
- `npm run smoke:agent-runtime-current-fixture`:通过;`liveProviderUsed=false`,包含 unknown Item 真实 Electron Gate B 场景。
- `npm run verify:gui-smoke`:通过;App Server version `1.119.0`。证据:`.lime/qc/project-gates/standalone-shell-01-20260801181323-22109/shell-01-electron-smoke/summary.json`
- `cargo fmt --manifest-path "lime-rs/Cargo.toml" --all --check`:通过。
- `git diff --check`:通过。
- 验证等级:协议/跨层门禁、Rust owner 测试、Current fixture 与真实 Electron Gate B 均已覆盖;未执行全量 `npm test`、裸 `npm run lint` 和全量 Cargo 矩阵,按当前发版规范不作为默认阻断项。
## 分类
- `current`App Server v2 typed MCP startup status、canonical unknown Item、command terminal interaction,以及对应 read model、客户端和 GUI 投影。
- `compat`:无新增。
- `deprecated`Refactor v2 尚未完成且缺少完整 producer、consumer、持久化语义或 Gate B 的 planned notification,仅允许继续迁出。
- `dead / deleted / forbidden-to-restore`:旧 MCP lifecycle Desktop events、unknown Item null drop/raw payload/Extension fallback、独立 `write_stdin` Tool Item、raw stdin 持久化与生产 mock fallback。
## 完成度
- 当前完成度:85%。候选范围、版本事实源、双语 release notes 和全部默认发布门禁已收口;剩余 Git 写操作、远端推送与 tag/workflow 复核待明确确认后执行。
+24 -24
View File
@@ -13,7 +13,7 @@
出口代码:TL 时间线,TP Turn 面板,PI pending interactionHS Header/状态区,GN 应用通知,DX 仅开发诊断。
实施快照:direct `item/started -> item/commandExecution/outputDelta* -> item/completed` 通过 typed adapter 和共享 reducerproduction `thread/resume` 安装同一 replay reducer,后续 live notification 继续复用。completed snapshot 权威覆盖 delta 草稿,输出限制为 256 KiB。`turn.plan.updated` 由 canonical `update_plan``ToolOutput.structured_content` 派生,实时与 canonical cold read 共用 checklist 投影;`update_plan` 工具项保留在 read model,但不生成 `ThreadItem.plan` 或 Plan UI。unknown Item unknown/known-unprojected notification drift recorder 已接线,但 recorder 只提供 fail-visible 诊断,不能把 72 notification 中的 planned surface 标记完成。
实施快照:direct `item/started -> item/commandExecution/outputDelta* -> item/completed` 通过 typed adapter 和共享 reducerproduction `thread/resume` 安装同一 replay reducer,后续 live notification 继续复用。completed snapshot 权威覆盖 delta 草稿,输出限制为 256 KiB。`write_stdin` 复用原始 `exec_command` Item identitytyped terminal interaction 与 canonical cold read 只保留 `sent N chars` 脱敏摘要。`turn.plan.updated` 由 canonical `update_plan``ToolOutput.structured_content` 派生,实时与 canonical cold read 共用 checklist 投影;`update_plan` 工具项保留在 read model,但不生成 `ThreadItem.plan` 或 Plan UI。unknown Item 已沿 canonical typed payload、v2 `thread/read`、Renderer 终态合并与 direct TurnTimeline fail-visible,只保留 upstream type 和脱敏字段名,并有专项 Electron Gate Bunknown/known-unprojected notification drift recorder 仍只提供诊断,不能把 72 notification 中的 planned surface 标记完成。
## 1. Thread、Turn 与 Hook
@@ -38,14 +38,14 @@
| 17 | hook/started | TL/HS | planned | Hook activityrun id 保留 |
| 18 | turn/completed | TL/TP | current | 权威 Turn 终态并清理 pending |
| 19 | hook/completed | TL | planned | Hook status、duration、entries 与阻断结果 |
| 20 | turn/diff/updated | TP | planned | 替换聚合 Diff snapshot |
| 20 | turn/diff/updated | DX | product-scope-excluded | Codex raw unified diffLime 以 canonical FileChange 为唯一事实源 |
| 21 | turn/plan/updated | TP | current | canonical update_plan checklist,实时/冷恢复一致 |
## 2. Item 生命周期、流与进程
| # | Method | 目标出口 | 当前裁决 | v2 投影 |
| --: | ----------------------------------------- | -------- | ---------------------- | ------------------------------------------- |
| 22 | item/started | TL | current | 按 18 类联合建立 Item;未知 fail visible |
| 22 | item/started | TL | current | 按 typed 联合建立 Item;未知安全 fail visible |
| 23 | item/autoApprovalReview/started | PI/TL | planned | 目标 Item 的 Guardian review 进行中 |
| 24 | item/autoApprovalReview/completed | PI/TL | planned | approved/denied/timedOut/aborted 与风险摘要 |
| 25 | item/completed | TL | current | Item 权威终态覆盖流式草稿 |
@@ -54,10 +54,10 @@
| 28 | item/agentMessage/delta | TL | current | 合批 Markdown stream |
| 29 | item/plan/delta | TL | current | 临时计划草稿,completed 整体替换 |
| 30 | command/exec/outputDelta | DX/GN | product-scope-excluded | 独立 command API,不能混入 Agent command |
| 31 | process/outputDelta | DX/GN | planned | 专用 process 面板,不推导 Turn 状态 |
| 32 | process/exited | DX/GN | planned | 终结 process 面板 |
| 31 | process/outputDelta | DX | product-scope-excluded | standalone unsandboxed process/spawn 流;不进入 Lime 产品链 |
| 32 | process/exited | DX | product-scope-excluded | standalone process 终态;不进入 Lime 产品链 |
| 33 | item/commandExecution/outputDelta | TL | current | 有界 stdout/stderr buffer |
| 34 | item/commandExecution/terminalInteraction | TL/DX | planned | 已发送输入的脱敏摘要 |
| 34 | item/commandExecution/terminalInteraction | TL/DX | current | 复用原 Command Itemtyped/cold read 仅保留脱敏摘要 |
| 35 | item/fileChange/outputDelta | DX | deprecated | 只记录兼容诊断,不覆盖 patch |
| 36 | item/fileChange/patchUpdated | TL/TP | current | replace changes snapshot |
| 37 | serverRequest/resolved | PI | current | 按 interaction identity 终结表单 |
@@ -65,21 +65,21 @@
## 3. MCP、账号、应用与系统资源
| # | Method | 目标出口 | 当前裁决 | v2 投影 |
| --: | ------------------------------------ | -------- | ---------------------- | ---------------------------------------- |
| 39 | mcpServer/oauthLogin/completed | GN/PI | current | 结束 OAuth 等待并刷新 MCP status |
| 40 | mcpServer/startupStatus/updated | HS/GN | planned | starting/ready/failed无假 MCP fallback |
| 41 | account/updated | GN | product-scope-excluded | 不写入对话 history |
| 42 | account/rateLimits/updated | HS/GN | product-scope-excluded | 若未来纳入,独立产品范围变更 |
| 43 | app/list/updated | GN | planned | 刷新 Apps/connectors/mention catalog |
| 44 | remoteControl/status/changed | GN | product-scope-excluded | 不进入 Thread |
| 45 | externalAgentConfig/import/progress | GN | product-scope-excluded | 仅设置页导入任务 |
| 46 | externalAgentConfig/import/completed | GN | product-scope-excluded | 同上 |
| 47 | fs/changed | GN/DX | planned | 只路由给对应 watch consumer |
| 48 | item/reasoning/summaryTextDelta | TL | current | 按 summaryIndex 更新分段 |
| 49 | item/reasoning/summaryPartAdded | TL | current | 创建指定 summary 分段 |
| 50 | item/reasoning/textDelta | TL | current | 按 contentIndex 更新受控原始推理 |
| 51 | thread/compacted | TL/DX | deprecated | 无 ContextCompaction Item 时仅显示一次 |
| # | Method | 目标出口 | 当前裁决 | v2 投影 |
| --: | ------------------------------------ | -------- | ---------------------- | ----------------------------------------- |
| 39 | mcpServer/oauthLogin/completed | GN/PI | current | 结束 OAuth 等待并刷新 MCP status |
| 40 | mcpServer/startupStatus/updated | HS/GN | current | typed starting/ready/failed终态刷新 GUI |
| 41 | account/updated | GN | product-scope-excluded | 不写入对话 history |
| 42 | account/rateLimits/updated | HS/GN | product-scope-excluded | 若未来纳入,独立产品范围变更 |
| 43 | app/list/updated | GN | planned | 刷新 Apps/connectors/mention catalog |
| 44 | remoteControl/status/changed | GN | product-scope-excluded | 不进入 Thread |
| 45 | externalAgentConfig/import/progress | GN | product-scope-excluded | 仅设置页导入任务 |
| 46 | externalAgentConfig/import/completed | GN | product-scope-excluded | 同上 |
| 47 | fs/changed | GN/DX | planned | 只路由给对应 watch consumer |
| 48 | item/reasoning/summaryTextDelta | TL | current | 按 summaryIndex 更新分段 |
| 49 | item/reasoning/summaryPartAdded | TL | current | 创建指定 summary 分段 |
| 50 | item/reasoning/textDelta | TL | current | 按 contentIndex 更新受控原始推理 |
| 51 | thread/compacted | TL/DX | deprecated | 无 ContextCompaction Item 时仅显示一次 |
## 4. Model、安全、告警与搜索
@@ -112,7 +112,7 @@
| 71 | windowsSandbox/setupCompleted | GN/PI | planned | setup success/error 与下一步 |
| 72 | account/login/completed | GN/PI | product-scope-excluded | credential 流程不进入对话 |
v2 的实现门槛不是把所有 planned method 同时实现,而是首先将这张表固化为类型检查的 coverage map。新增 Codex method 时,CI 必须要求它先获得裁决,不能落入 default silent return。
v2 的实现门槛不是把所有 planned method 同时实现,而是首先将这张表固化为类型检查的 coverage map。新增 Codex method 时,CI 必须要求它先获得裁决,不能落入 default silent return。`turn/diff/updated``process/outputDelta``process/exited` 虽保留在 upstream method inventory 和 drift recorder 中,但明确为 `product-scope-excluded`:不得进入 Lime current protocol、Renderer projector、时间线或用户级通知;对应 standalone `process/spawn` 控制面与 raw unified diff 不能借 planned 名义回流。
## 6. Lime-owned 扩展事件
@@ -188,5 +188,5 @@ Turn plan 是 canonical `update_plan` 的执行 checklist,不是 ThreadItem.pl
- MCP content 的 text/image/audio/resource/resourceLink/unknown JSON、structuredContent 和 error
- approval 的 accept/session/policy/network/decline/cancel 与 Guardian 五终态;
- 乱序、重复、late delta、completed overwrite、disconnect、resume/read、itemsView 非 full
- unknown Item/notification fail visibleunknown reverse request fail closed
- Gate B 真实覆盖 Markdown、Search、Shell output、Diff、MCP、dynamic tool、审批、用户输入、interrupt、媒体历史恢复。
- unknown Item live/terminal/cold read 均 fail visible 且不泄漏 raw valuesunknown notification fail visibleunknown reverse request fail closed
- Gate B 真实覆盖 Markdown、Search、Shell output、Diff、MCP、dynamic tool、审批、用户输入、interrupt、媒体历史恢复与 unknown Item 专项恢复
+66 -11
View File
@@ -4,9 +4,9 @@
主目标:以 Codex 的 Thread / Turn / Item 生命周期和渲染语义替换 Lime 当前把 canonical Item 二次压缩为 Message、tool_call、extension 的对话渲染路径,同时保持 model-provider 的 Grok 多模型控制面和 OpenCode 多模态/多协议 lowering 不变。
当前阶段:V2-05 notification、host capability 与 recoveryV2-00、V2-01、V2-02、V2-03、V2-04 已关闭,direct TurnTimeline、production replay、session history、长列表性能、MCP elicitation、Multi-Agent、三项 product-scope reverse request、media read v2、`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` checklist`mcpServer/oauthLogin/completed` Gate B 已通过
当前阶段:V2-05 notification、host capability 与 recoveryV2-00、V2-01、V2-02、V2-03、V2-04 已关闭,direct TurnTimeline、production replay、session history、长列表性能、MCP elicitation、Multi-Agent、三项 product-scope reverse request、media read v2、unknown Item fail-visible recovery、`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` checklist`mcpServer/oauthLogin/completed``mcpServer/startupStatus/updated` 与 unified exec terminal interaction Gate B 已通过
下一刀:V2-05 已关闭 media transient bypass、`configWarning` owner 迁移、thread-scoped `warning` typed/recovery、`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` checklistMCP OAuth typed completion 链;回到 `EVENT-PROJECTIONS.md` 审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recovery`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。不重复改写已关闭的 V2-04 owner,也不恢复 Message synthesis、extension fallback、v0 media/config owner、旧 MCP OAuth Desktop event、旧 Team 工具、第二 pending store、第二 Skill catalog owner、Plan ThreadItem 或由 error 抢占 Turn terminal 的旁路状态机。
下一刀:V2-05 已关闭 media transient bypass、unknown Item fail-visible recovery、`configWarning` owner 迁移、thread-scoped `warning` typed/recovery、`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` checklistMCP OAuth completion、MCP startup status 与 unified exec terminal interaction typed/recovery 链;`turn/diff/updated` 与 standalone `process/*` 已裁决为 product-scope-excluded。继续审计具备真实 producer/consumer 的 planned notification、host capability 或 recovery`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。不重复改写已关闭 owner,也不恢复 raw unified diff、unsandboxed process/spawn、Message synthesis、unknown null drop、extension fallback、v0 media/config owner、旧 MCP Desktop lifecycle event、旧 Team 工具、第二 pending store、第二 Skill catalog owner、Plan ThreadItem、独立 `write_stdin` Tool Item 或由 error 抢占 Turn terminal 的旁路状态机。
## 1. 约束与非目标
@@ -197,9 +197,9 @@
动作:
1. 按 EVENT-PROJECTIONS 逐项补 Hook、Turn plan/diff、environment、warning、fuzzy search、process、realtime、Windows sandbox 等 planned surface。
1. 按 EVENT-PROJECTIONS 逐项补 Hook、environment、warning、fuzzy search、realtime、Windows sandbox 等仍在产品范围内的 planned surface`turn/diff/updated` 与 standalone `process/*` 已裁决为 product-scope-excluded,不得恢复为 current
2. 每项先补 App Server typed protocol 和 producer,再补 Electron gateway、projection、renderer 和 Gate B;不能由前端先造状态。
3. 维护已落地的 unknown notification drift recorderknown-but-excluded 发 DXunknown notification fail visibleunknown reverse request fail closed。
3. 维护已落地的 unknown notification drift recorderknown-but-excluded 发 DXunknown notification fail visible`turn/diff/updated``process/*` 只保留脱敏字段名诊断,不进入 Renderer;unknown reverse request fail closed。
4. 移除已被 direct ItemRenderer 替代的旧 Message hydration、stream merge、legacy event parser、compat DTO 和测试夹具。
5. 在同一变更集中更新 internal/aiprompts/architecture.md,记录 Renderer read path 从 Message synthesis 收敛到 ConversationProjection,并完成架构图确认。
@@ -252,14 +252,15 @@ Gate B 最少场景:Agent Markdown、Reasoning、Plan、Search、Shell output
## 5. 分类与完成度
| 分类 | v2 裁决 |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| current | App Server v2 主链、ConversationProjection、direct TurnTimeline、bounded restored Turn window、canonical long-message preview、production replay、统一 PendingInteraction、thread-scoped media read、typed `configWarning` producer/GUI、thread-scoped typed `warning` 与 durable recovery、typed `skills/changed` catalog invalidation 与 GUI 自动重读、typed `error` live/durable recovery 与 Turn terminal ownership、`turn/plan/updated` canonical checklist 与 cold recovery、typed `mcpServer/oauthLogin/completed` 与 GUI 自动刷新、六工具 AgentControl、canonical SubAgent activity、parent-owned child direct-input policy、`currentTime/read``item/permissions/requestApproval``item/tool/call`、typed DynamicToolCall、model-provider 多模型/多模态 owner |
| compat | 不保留长期生产 compat;仅可存在一次性迁移测试夹具 |
| deprecated | fileChange outputDelta、thread/compacted 与尚未完成 current producer 的旧 notification 裁决;只允许迁出 |
| dead / deleted | canonical Item -> Message tool/agent/reasoning 合成、首帧无界历史挂载、canonical 长正文绕过 preview、unknown Item null drop、ContextCompaction hide、通用 extension fallback、重复 pending store、v0 media/config notification owner、media transient notification/raw subscription/live-drain 旁路、旧 MCP OAuth Desktop event、裸旧 Team 工具、raw output 状态推断与生产 mock fallback |
| 分类 | v2 裁决 |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| current | App Server v2 主链、ConversationProjection、direct TurnTimeline、bounded restored Turn window、canonical long-message preview、production replay、统一 PendingInteraction、thread-scoped media read、typed unknown Item live/cold fail-visible 与专项 Gate B、typed `configWarning` producer/GUI、thread-scoped typed `warning` 与 durable recovery、typed `skills/changed` catalog invalidation 与 GUI 自动重读、typed `error` live/durable recovery 与 Turn terminal ownership、`turn/plan/updated` canonical checklist 与 cold recovery、typed `mcpServer/oauthLogin/completed` 与 GUI 自动刷新、typed `mcpServer/startupStatus/updated` 与 MCP 连接态投影、unified exec terminal interaction typed/cold recovery、六工具 AgentControl、canonical SubAgent activity、parent-owned child direct-input policy、`currentTime/read``item/permissions/requestApproval``item/tool/call`、typed DynamicToolCall、model-provider 多模型/多模态 owner |
| compat | 不保留长期生产 compat;仅可存在一次性迁移测试夹具 |
| product-scope-excluded | `turn/diff/updated` raw unified diff、standalone unsandboxed `process/outputDelta` / `process/exited`;只保留 upstream inventory 与脱敏 drift 诊断,不进入 current protocol 或 Renderer |
| deprecated | fileChange outputDelta、thread/compacted 与尚未完成 current producer 的旧 notification 裁决;只允许迁出 |
| dead / deleted | canonical Item -> Message tool/agent/reasoning 合成、首帧无界历史挂载、canonical 长正文绕过 preview、unknown Item null drop、ContextCompaction hide、通用 extension fallback、重复 pending store、v0 media/config notification owner、media transient notification/raw subscription/live-drain 旁路、旧 MCP OAuth/start/stop/error Desktop event、裸旧 Team 工具、raw output 状态推断与生产 mock fallback |
计划完成度:保守估算 95%。该数字按 V2-00 至 V2-05 六个阶段退出条件与剩余风险折算:V2-00、V2-01、V2-02、V2-03、V2-04 已关闭,V2-05 的 media transient bypass、`configWarning` typed owner、thread-scoped `warning` typed/recovery、`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` canonical checklistMCP OAuth typed completion 链已关闭;其余 planned notification、host capability 与全面 recovery 仍未完成。该估算不表示 v2 已可整体交付或 release-ready。
计划完成度:保守估算 97%。该数字按 V2-00 至 V2-05 六个阶段退出条件与剩余风险折算:V2-00、V2-01、V2-02、V2-03、V2-04 已关闭,V2-05 的 media transient bypass、unknown Item fail-visible recovery、`configWarning` typed owner、thread-scoped `warning` typed/recovery、`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` canonical checklistMCP OAuth completion、MCP startup status 与 unified exec terminal interaction typed/recovery 链已关闭;`turn/diff/updated` 与 standalone `process/*` 已明确排除出 Lime 产品范围,其余 planned notification、host capability 与全面 recovery 仍未完成。该估算不表示 v2 已可整体交付或 release-ready。
## 6. 执行台账
@@ -718,3 +719,57 @@ Gate B 证据:
治理分类:typed protocol/schema、App Server completion producer、strict client decoder、Renderer typed event bus 与自动刷新为 `current`;无 `compat``mcp:oauth_completed``dead / deleted / forbidden-to-restore`。本切片没有改变 public owner、唯一产品链或依赖方向,无需改写 `internal/aiprompts/architecture.md`。责任开发者确认:root2026-07-31。
下一刀:回到 `EVENT-PROJECTIONS.md` 继续审计具备真实 producer/consumer 的 planned notification、host capability 或 recovery`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 95%,仍非 release-ready。
### 2026-07-31V2-05 MCP startup status typed notification
状态:该垂直切片 `completed`V2-05 与 v2 总体仍为 `in-progress`,不得标记 release-ready。
本轮实现:
- protocol v2、schema 与 generated TypeScript 新增 Codex 对齐的 `mcpServer/startupStatus/updated` contractpayload 固定为必填 `threadId``name``status``error``failureReason`status 为 `starting | ready | failed | cancelled`unknown/missing field fail closed。
- `mcpServer/start` 在调用 RuntimeCore 前发布 `starting`,成功发布 `ready`,失败发布带 error 的 `failed` 后保留原 JSON-RPC error。Renderer 同步订阅 typed event bus`starting` 投影连接态,终态刷新 `mcpServerStatus/list``mcpTool/list`,失败同步更新连接错误和全局错误。
- 生产路径删除 `mcp:server_started``mcp:server_stopped``mcp:server_error`contract guard 与测试禁止四条 legacy MCP lifecycle event(含已删除的 `mcp:oauth_completed`)回流 `electron``lime-rs``packages``src`。资源与工具更新事件保留在各自 current owner,本切片不扩张。
验证:`lime-mcp` 151/151、App Server protocol MCP 6/6、App Server MCP processor 6/6、Hook 10/10、app-server-client 102/102、MCP workspace fixture 3/3`npm run test:contracts` 通过,包含 850 schema definitions、842 generated protocol types、0 generation failures 与 292 app-server-client checks`npm run test:rust:related -- <MCP paths>``npm run governance:legacy-report``npm run governance:scripts``npm run verify:gui-smoke` 均通过。
产品证据:`npm run smoke:mcp-startup-notification-electron-fixture` 通过,proof level 为 `Gate B`。真实 Electron/preload/IPC、`app_server_handle_json_lines`、App Server event drain、runtime MCP server 与 Settings GUI 全链命中;成功服务器可见 `starting -> ready`,失败服务器可见 `starting -> failed`,自动刷新 `mcpServerStatus/list``mcpTool/list`。证据中 `appServerHandleJsonLinesHitCount=4``appServerDrainEventsHitCount=7``electronIpcHitCount=11``mockFallbackHitCount=0``failedInvokeCount=0`legacy MCP commands、console/page/invoke errors 均为 0。证据为 `.lime/qc/mcp-startup-notification/mcp-startup-notification-fixture-summary.json` 与同目录 PNG。
治理分类:typed protocol/schema、App Server startup producer、strict client decoder、Renderer typed event bus、GUI 连接态与专用 Gate B 为 `current`;无 `compat``mcp:server_started``mcp:server_stopped``mcp:server_error``dead / deleted / forbidden-to-restore`。本切片没有改变 public owner、唯一产品链或依赖方向,无需改写 `internal/aiprompts/architecture.md`。责任开发者确认:root2026-07-31。
下一刀:回到 `EVENT-PROJECTIONS.md` 继续审计具备真实 producer/consumer 的 planned notification、host capability 或 recovery`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 96%,仍非 release-ready。
### 2026-07-31V2-05 unknown Item fail-visible recovery Gate B
状态:该垂直切片 `completed`V2-05 与 v2 总体仍为 `in-progress`,不得标记 release-ready。
本轮实现:
- canonical `ThreadItemPayload::Unknown` 与 v2 `ThreadItem::UnknownItem` 只保留 upstream type 和排序、限量、敏感名脱敏后的字段名;raw values 与 event metadata 不进入持久化、read model 或 GUIunknown 数据不得退回 `Extension` escape hatch。
- App Server materializer、canonical notification、thread projection 与 read model 使用同一 thread/turn/item identity`item.started -> item.completed -> turn.completed` 后,`thread/read` 仍恢复同一个 completed Unknown。
- Renderer 终态 read-model snapshot 现会把 canonical `thread_items` 合入 direct timeline。此前 snapshot 只更新 `threadRead`,导致 live Unknown 在 terminal refresh 后消失;完成态历史摘要现持续显示同一安全诊断。
验证:定向 Vitest 66/66unknown fixture/合并/UI 回归 23/23`npm run typecheck``npm run test:contracts``npm run governance:scripts``npm run governance:legacy-report``git diff --check` 通过。`npm run smoke:agent-runtime-current-fixture` 聚合门禁完整通过且 `liveProviderUsed=false``npm run verify:gui-smoke` 21/21 assertions 通过,证据为 `.lime/qc/project-gates/standalone-shell-01-20260731194839-48452/shell-01-electron-smoke/summary.json``npm run test:rust:related -- <Unknown Item paths>` 按反向依赖扩圈并以退出码 0 完成,关键结果包括 agent-protocol 41/41、app-server 1659/1659、app-server-protocol 99/99、lime-mcp 151/151。
产品证据:`npm run smoke:unknown-item-recovery-electron-fixture` 通过,proof level 为 `Gate B controlled fixture`。真实 Electron/preload/IPC、`app_server_handle_json_lines`、App Server external runtime/read model 与 direct TurnTimeline 全链命中;GUI 只有 1 个 Unknown`futureCapability``[redacted] / label / opaquePayload / status` 可见,raw values、secret 与 `unknown_item` 内部名不可见;read model 恢复同一 completed Itemproduction mock fallback 与 console/page/invoke errors 均为 0。证据为 `.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-unknown-item-regression-summary.json`
治理分类:typed Unknown protocol/canonical payload、App Server materializer/read model、Renderer terminal merge、GUI fail-visible 与专项 Gate B 为 `current`;无 `compat`unknown Item null drop、raw payload/metadata 透传、Extension fallback 与生产 mock fallback 为 `dead / deleted / forbidden-to-restore`
架构确认:本切片没有改变 public owner、唯一产品链或依赖方向,只补齐既有 canonical Item -> v2 read model -> direct TurnTimeline 的 typed 安全投影和终态恢复;无需改写 `internal/aiprompts/architecture.md`。责任开发者确认:root2026-07-31。
下一刀:回到 `EVENT-PROJECTIONS.md` 继续审计具备真实 producer/consumer 的 planned notification、host capability 或 recovery`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 97%,仍非 release-ready。
### 2026-08-01V2-05 unified exec terminal interaction
状态:该垂直切片 `completed`V2-05 与 v2 总体仍为 `in-progress`,不得标记 release-ready。
本轮实现:
- `write_stdin` 复用原始 `exec_command` Command Item identity;其 Started 事件不创建第二个 Tool Item,完成后释放 session 绑定,跨 Thread 写入 fail closed。
- App Server 通过 typed `item/commandExecution/terminalInteraction` 投影仅含 `sent N chars` 的脱敏摘要,并写入 canonical `CommandExecution.terminalInteractions`;原始 stdin 不进入通知、持久化、read model 或 GUI。
- Renderer live timeline、historical direct timeline 与 reload recovery 消费同一 canonical 字段;Electron fixture 先以 current `thread/resume` 建立订阅,再从真实 `app_server_drain_events` 等待后台 typed notification,不修改生产协议。
验证:tool-runtime 跨 Thread/原 Item 回归、Renderer 7 files / 159 tests、fixture guard 6/6、`npm run test:contracts`、相关 Rust/SDK 测试、`npm run smoke:agent-runtime-current-fixture``npm run verify:gui-smoke` 均通过。专项 `npm run smoke:codex-import-continuation-electron-fixture -- --timeout-ms 240000` 证明导入与普通会话 completed Command Item 同构,均只保存 `sent 9 chars`,实时与 reload GUI 不显示原始 stdin。专项证据为 `.lime/qc/gui-evidence/codex-import-continuation-fixture/codex-import-continuation-fixture-summary.json`;通用 GUI smoke 证据为 `.lime/qc/project-gates/standalone-shell-01-20260801154238-47878/shell-01-electron-smoke/summary.json`
治理分类:unified exec、canonical terminal interactions、typed notification、Renderer live/historical projection 与 Gate B fixture 为 `current`;无 `compat`;原始 stdin、独立 `write_stdin` Tool Item、retired Bash/PowerShell tools 与生产 mock fallback 为 `dead / forbidden-to-restore`。本切片未改变 public owner、唯一产品链或依赖方向,无需改写 `internal/aiprompts/architecture.md`。责任开发者确认:root2026-08-01。
下一刀:其余 planned notification 在当前工作树仍缺少完整 producer、consumer、持久化语义与 Gate B,保持 planned;不得为提高完成度新增协议 facade、compat 或生产 mock。v2 总体完成度保守估算仍为 97%,仍非 release-ready。
@@ -69,7 +69,7 @@
{ "method": "hook/started", "outlets": ["timeline", "header"] },
{ "method": "turn/completed", "outlets": ["timeline", "turnPanel"] },
{ "method": "hook/completed", "outlets": ["timeline"] },
{ "method": "turn/diff/updated", "outlets": ["turnPanel"] },
{ "method": "turn/diff/updated", "outlets": ["diagnostics"] },
{ "method": "turn/plan/updated", "outlets": ["turnPanel"] },
{ "method": "item/started", "outlets": ["timeline"] },
{
@@ -97,11 +97,8 @@
"method": "command/exec/outputDelta",
"outlets": ["diagnostics", "globalNotice"]
},
{
"method": "process/outputDelta",
"outlets": ["diagnostics", "globalNotice"]
},
{ "method": "process/exited", "outlets": ["diagnostics", "globalNotice"] },
{ "method": "process/outputDelta", "outlets": ["diagnostics"] },
{ "method": "process/exited", "outlets": ["diagnostics"] },
{ "method": "item/commandExecution/outputDelta", "outlets": ["timeline"] },
{
"method": "item/commandExecution/terminalInteraction",
+30 -30
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-protocol"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"schemars",
"serde",
@@ -30,7 +30,7 @@ dependencies = [
[[package]]
name = "agent-runtime"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"agent-protocol",
"anyhow",
@@ -181,7 +181,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "app-server"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"agent-protocol",
"agent-runtime",
@@ -242,7 +242,7 @@ dependencies = [
[[package]]
name = "app-server-client"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"app-server-protocol",
"app-server-transport",
@@ -253,7 +253,7 @@ dependencies = [
[[package]]
name = "app-server-daemon"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"app-server-protocol",
"app-server-transport",
@@ -265,7 +265,7 @@ dependencies = [
[[package]]
name = "app-server-protocol"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"agent-protocol",
"schemars",
@@ -275,7 +275,7 @@ dependencies = [
[[package]]
name = "app-server-test-client"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"anyhow",
"app-server-client",
@@ -286,7 +286,7 @@ dependencies = [
[[package]]
name = "app-server-transport"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"app-server-protocol",
"axum",
@@ -1356,7 +1356,7 @@ dependencies = [
[[package]]
name = "document-preview"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"flate2",
"tempfile",
@@ -2619,7 +2619,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"agent-protocol",
"agent-runtime",
@@ -2652,7 +2652,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"chrono",
"futures",
@@ -2669,7 +2669,7 @@ dependencies = [
[[package]]
name = "lime-cli"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"clap",
"lime-core",
@@ -2681,7 +2681,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"async-trait",
"lime-core",
@@ -2697,7 +2697,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"agent-protocol",
"async-trait",
@@ -2753,7 +2753,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"aes",
"async-trait",
@@ -2781,7 +2781,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"chrono",
"dashmap",
@@ -2803,7 +2803,7 @@ dependencies = [
[[package]]
name = "lime-knowledge"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"chrono",
"hex",
@@ -2816,7 +2816,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"async-trait",
"axum",
@@ -2842,7 +2842,7 @@ dependencies = [
[[package]]
name = "lime-media-runtime"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"axum",
"base64 0.22.1",
@@ -2861,7 +2861,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"async-trait",
"lime-core",
@@ -2901,7 +2901,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"agent-protocol",
"async-stream",
@@ -2944,7 +2944,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"axum",
"futures",
@@ -2959,7 +2959,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"anyhow",
"async-trait",
@@ -3002,7 +3002,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -3023,7 +3023,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"axum",
"chrono",
@@ -3232,7 +3232,7 @@ dependencies = [
[[package]]
name = "model-provider"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"agent-protocol",
"anyhow",
@@ -3902,7 +3902,7 @@ checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
[[package]]
name = "patch-apply"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"tempfile",
"thiserror 1.0.69",
@@ -4661,7 +4661,7 @@ dependencies = [
[[package]]
name = "runtime-core"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"agent-protocol",
"app-server-protocol",
@@ -5409,7 +5409,7 @@ dependencies = [
[[package]]
name = "thread-store"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"agent-protocol",
"chrono",
@@ -5687,7 +5687,7 @@ dependencies = [
[[package]]
name = "tool-runtime"
version = "1.118.0"
version = "1.119.0"
dependencies = [
"agent-protocol",
"app-server-protocol",
+1 -1
View File
@@ -6,7 +6,7 @@ exclude = [
resolver = "2"
[workspace.package]
version = "1.118.0"
version = "1.119.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
+9 -2
View File
@@ -139,6 +139,7 @@ pub enum ItemKind {
Media,
SubAgent,
ContextCompaction,
Unknown,
Extension,
}
@@ -294,8 +295,8 @@ pub struct PlanStep {
pub status: PlanStepStatus,
}
/// Typed Item payload union. JSON `Value` is intentionally confined to the
/// extension escape hatch; structured core families use explicit fields.
/// Typed Item payload union. Unknown upstream items retain only their type and
/// sanitized field names; raw values must not cross the canonical boundary.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ThreadItemPayload {
@@ -435,6 +436,11 @@ pub enum ThreadItemPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
tail_start_turn_id: Option<String>,
},
Unknown {
upstream_type: String,
#[serde(default)]
field_names: Vec<String>,
},
Extension {
name: String,
data: Value,
@@ -458,6 +464,7 @@ impl ThreadItemPayload {
Self::Media { .. } => ItemKind::Media,
Self::SubAgent { .. } => ItemKind::SubAgent,
Self::ContextCompaction { .. } => ItemKind::ContextCompaction,
Self::Unknown { .. } => ItemKind::Unknown,
Self::Extension { .. } => ItemKind::Extension,
}
}
@@ -74,8 +74,13 @@ impl CurrentTurnToolLifecycleEmitter {
}
pub(super) fn project_all(&self, event: ToolLifecycleEvent) -> Vec<AgentEvent> {
let terminal = matches!(event.phase, ToolLifecyclePhase::Completed);
if terminal && event.output.is_none() {
let lifecycle_completed = matches!(event.phase, ToolLifecyclePhase::Completed);
if lifecycle_completed && event.output.is_none() {
return Vec::new();
}
if event.tool_name == tool_runtime::unified_exec::WRITE_STDIN_TOOL_NAME
&& matches!(event.phase, ToolLifecyclePhase::Started)
{
return Vec::new();
}
@@ -84,7 +89,13 @@ impl CurrentTurnToolLifecycleEmitter {
let dynamic_tool_route = self.dynamic_tool_routes.get(&event.tool_name);
let dynamic_tool_arguments = dynamic_tool_route.as_ref().map(|_| event.arguments.clone());
let now = chrono::Utc::now().timestamp_millis();
let key = format!("{}\0{}", event.turn_id, event.call_id);
let lifecycle_call_id = event
.output
.as_ref()
.and_then(|output| output.metadata.get("exec_command_call_id"))
.and_then(serde_json::Value::as_str)
.unwrap_or(&event.call_id);
let key = format!("{}\0{}", event.turn_id, lifecycle_call_id);
let state = {
let mut items = self
.items
@@ -98,9 +109,6 @@ impl CurrentTurnToolLifecycleEmitter {
items.insert(key.clone(), state);
state
});
if terminal {
items.remove(&key);
}
state
};
let event_count = if subagent_activity.is_some() { 2 } else { 1 };
@@ -117,6 +125,12 @@ impl CurrentTurnToolLifecycleEmitter {
updated_at_ms: now,
},
) {
if matches!(&event, AgentEvent::ItemCompleted { .. }) {
self.items
.lock()
.expect("tool item lifecycle mutex poisoned")
.remove(&key);
}
let event = project_dynamic_tool_event(
event,
dynamic_tool_route.as_ref(),
File diff suppressed because it is too large Load Diff
@@ -2218,6 +2218,8 @@
"CommandExecutionRequestApprovalResponse",
"CommandExecutionSource",
"CommandExecutionStatus",
"CommandExecutionTerminalInteraction",
"CommandExecutionTerminalInteractionNotification",
"ConfigWarningNotification",
"CurrentTimeReadParams",
"CurrentTimeReadResponse",
@@ -2251,6 +2253,9 @@
"McpServerElicitationRequestParams",
"McpServerElicitationRequestResponse",
"McpServerOauthLoginCompletedNotification",
"McpServerStartupFailureReason",
"McpServerStartupState",
"McpServerStatusUpdatedNotification",
"McpToolCallAppContext",
"McpToolCallError",
"McpToolCallProgressNotification",
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"title": "CommandExecutionTerminalInteraction",
"type": "object"
}
@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"itemId": {
"type": "string"
},
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"itemId",
"processId",
"stdin",
"threadId",
"turnId"
],
"title": "CommandExecutionTerminalInteractionNotification",
"type": "object"
}
@@ -180,6 +180,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -712,6 +727,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1103,6 +1124,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -180,6 +180,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -712,6 +727,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1103,6 +1124,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -0,0 +1,8 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"enum": [
"reauthenticationRequired"
],
"title": "McpServerStartupFailureReason",
"type": "string"
}
@@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"enum": [
"cancelled",
"failed",
"ready",
"starting"
],
"title": "McpServerStartupState",
"type": "string"
}
@@ -0,0 +1,60 @@
{
"$defs": {
"McpServerStartupFailureReason": {
"enum": [
"reauthenticationRequired"
],
"type": "string"
},
"McpServerStartupState": {
"enum": [
"cancelled",
"failed",
"ready",
"starting"
],
"type": "string"
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"error": {
"type": [
"null",
"string"
]
},
"failureReason": {
"anyOf": [
{
"$ref": "#/$defs/McpServerStartupFailureReason"
},
{
"type": "null"
}
]
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/$defs/McpServerStartupState"
},
"threadId": {
"type": [
"null",
"string"
]
}
},
"required": [
"error",
"failureReason",
"name",
"status",
"threadId"
],
"title": "McpServerStatusUpdatedNotification",
"type": "object"
}
@@ -399,6 +399,48 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"CommandExecutionTerminalInteractionNotification": {
"properties": {
"itemId": {
"type": "string"
},
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"itemId",
"processId",
"stdin",
"threadId",
"turnId"
],
"type": "object"
},
"ConfigWarningNotification": {
"properties": {
"details": {
@@ -722,6 +764,62 @@
],
"type": "object"
},
"McpServerStartupFailureReason": {
"enum": [
"reauthenticationRequired"
],
"type": "string"
},
"McpServerStartupState": {
"enum": [
"cancelled",
"failed",
"ready",
"starting"
],
"type": "string"
},
"McpServerStatusUpdatedNotification": {
"additionalProperties": false,
"properties": {
"error": {
"type": [
"null",
"string"
]
},
"failureReason": {
"anyOf": [
{
"$ref": "#/$defs/McpServerStartupFailureReason"
},
{
"type": "null"
}
]
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/$defs/McpServerStartupState"
},
"threadId": {
"type": [
"null",
"string"
]
}
},
"required": [
"error",
"failureReason",
"name",
"status",
"threadId"
],
"type": "object"
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
@@ -1730,6 +1828,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -2121,6 +2225,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -2812,6 +2943,22 @@
],
"type": "object"
},
{
"properties": {
"method": {
"const": "mcpServer/startupStatus/updated",
"type": "string"
},
"params": {
"$ref": "#/$defs/McpServerStatusUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"type": "object"
},
{
"properties": {
"method": {
@@ -3036,6 +3183,22 @@
],
"type": "object"
},
{
"properties": {
"method": {
"const": "item/commandExecution/terminalInteraction",
"type": "string"
},
"params": {
"$ref": "#/$defs/CommandExecutionTerminalInteractionNotification"
}
},
"required": [
"method",
"params"
],
"type": "object"
},
{
"properties": {
"method": {
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -889,6 +904,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1280,6 +1301,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -1054,6 +1069,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1445,6 +1466,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -180,6 +180,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -850,6 +865,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1241,6 +1262,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
],
"title": "ThreadItem"
@@ -180,6 +180,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -712,6 +727,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1103,6 +1124,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -180,6 +180,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -712,6 +727,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1103,6 +1124,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -1030,6 +1045,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1421,6 +1442,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -1030,6 +1045,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1421,6 +1442,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -1030,6 +1045,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1421,6 +1442,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -1054,6 +1069,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1445,6 +1466,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -1030,6 +1045,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1421,6 +1442,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -1030,6 +1045,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1421,6 +1442,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -1054,6 +1069,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1445,6 +1466,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -1030,6 +1045,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1421,6 +1442,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -852,6 +867,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1243,6 +1264,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -1030,6 +1045,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1421,6 +1442,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -852,6 +867,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1243,6 +1264,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -852,6 +867,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1243,6 +1264,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -852,6 +867,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1243,6 +1264,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -852,6 +867,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1243,6 +1264,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -313,6 +313,21 @@
],
"type": "string"
},
"CommandExecutionTerminalInteraction": {
"properties": {
"processId": {
"type": "string"
},
"stdin": {
"type": "string"
}
},
"required": [
"processId",
"stdin"
],
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -852,6 +867,12 @@
"status": {
"$ref": "#/$defs/CommandExecutionStatus"
},
"terminalInteractions": {
"items": {
"$ref": "#/$defs/CommandExecutionTerminalInteraction"
},
"type": "array"
},
"type": {
"const": "commandExecution",
"type": "string"
@@ -1243,6 +1264,33 @@
"type"
],
"type": "object"
},
{
"properties": {
"fieldNames": {
"items": {
"type": "string"
},
"type": "array"
},
"id": {
"type": "string"
},
"type": {
"const": "unknownItem",
"type": "string"
},
"upstreamType": {
"type": "string"
}
},
"required": [
"fieldNames",
"id",
"type",
"upstreamType"
],
"type": "object"
}
]
},
@@ -1,15 +1,17 @@
use super::{
AgentMessageDeltaNotification, ArtifactWriteParams, ArtifactWriteResponse,
CommandExecutionOutputDeltaNotification, CommandExecutionRequestApprovalParams,
ConfigWarningNotification, CurrentTimeReadParams, DynamicToolCallParams, ErrorNotification,
CommandExecutionTerminalInteractionNotification, ConfigWarningNotification,
CurrentTimeReadParams, DynamicToolCallParams, ErrorNotification,
FileChangePatchUpdatedNotification, FileChangeRequestApprovalParams, ItemCompletedNotification,
ItemStartedNotification, McpServerElicitationRequestParams,
McpServerOauthLoginCompletedNotification, McpToolCallProgressNotification, MediaReadParams,
MediaReadResponse, Method, ModelListParams, ModelListUpdatedNotification,
ModelReroutedNotification, ModelSafetyBufferingUpdatedNotification,
ModelVerificationNotification, PermissionsRequestApprovalParams, PlanDeltaNotification,
ReasoningSummaryPartAddedNotification, ReasoningSummaryTextDeltaNotification,
ReasoningTextDeltaNotification, ServerRequestResolvedNotification, SkillsChangedNotification,
McpServerOauthLoginCompletedNotification, McpServerStatusUpdatedNotification,
McpToolCallProgressNotification, MediaReadParams, MediaReadResponse, Method, ModelListParams,
ModelListUpdatedNotification, ModelReroutedNotification,
ModelSafetyBufferingUpdatedNotification, ModelVerificationNotification,
PermissionsRequestApprovalParams, PlanDeltaNotification, ReasoningSummaryPartAddedNotification,
ReasoningSummaryTextDeltaNotification, ReasoningTextDeltaNotification,
ServerRequestResolvedNotification, SkillsChangedNotification,
ThreadApproveGuardianDeniedActionParams, ThreadApproveGuardianDeniedActionResponse,
ThreadArchiveParams, ThreadArchiveResponse, ThreadArchivedNotification,
ThreadBackgroundTerminalsCleanParams, ThreadBackgroundTerminalsCleanResponse,
@@ -37,18 +39,19 @@ use super::{
TurnCompletedNotification, TurnInterruptParams, TurnInterruptResponse,
TurnPlanUpdatedNotification, TurnStartParams, TurnStartResponse, TurnStartedNotification,
TurnSteerParams, TurnSteerResponse, WarningNotification, METHOD_COMMAND_EXECUTION_OUTPUT_DELTA,
METHOD_CONFIG_WARNING, METHOD_CURRENT_TIME_READ, METHOD_ERROR,
METHOD_FILE_CHANGE_PATCH_UPDATED, METHOD_ITEM_COMMAND_EXECUTION_REQUEST_APPROVAL,
METHOD_COMMAND_EXECUTION_TERMINAL_INTERACTION, METHOD_CONFIG_WARNING, METHOD_CURRENT_TIME_READ,
METHOD_ERROR, METHOD_FILE_CHANGE_PATCH_UPDATED, METHOD_ITEM_COMMAND_EXECUTION_REQUEST_APPROVAL,
METHOD_ITEM_FILE_CHANGE_REQUEST_APPROVAL, METHOD_ITEM_PERMISSIONS_REQUEST_APPROVAL,
METHOD_ITEM_TOOL_CALL, METHOD_ITEM_TOOL_REQUEST_USER_INPUT,
METHOD_MCP_SERVER_ELICITATION_REQUEST, METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED,
METHOD_MCP_TOOL_CALL_PROGRESS, METHOD_MODEL_LIST_UPDATED, METHOD_MODEL_REROUTED,
METHOD_MODEL_SAFETY_BUFFERING_UPDATED, METHOD_MODEL_VERIFICATION, METHOD_PLAN_DELTA,
METHOD_REASONING_SUMMARY_PART_ADDED, METHOD_REASONING_SUMMARY_TEXT_DELTA,
METHOD_REASONING_TEXT_DELTA, METHOD_SERVER_REQUEST_RESOLVED, METHOD_SKILLS_CHANGED,
METHOD_THREAD_CLOSED, METHOD_THREAD_GOAL_CLEARED, METHOD_THREAD_GOAL_UPDATED,
METHOD_THREAD_NAME_UPDATED, METHOD_THREAD_STATUS_CHANGED, METHOD_THREAD_TOKEN_USAGE_UPDATED,
METHOD_TURN_PLAN_UPDATED, METHOD_WARNING,
METHOD_MCP_SERVER_STARTUP_STATUS_UPDATED, METHOD_MCP_TOOL_CALL_PROGRESS,
METHOD_MODEL_LIST_UPDATED, METHOD_MODEL_REROUTED, METHOD_MODEL_SAFETY_BUFFERING_UPDATED,
METHOD_MODEL_VERIFICATION, METHOD_PLAN_DELTA, METHOD_REASONING_SUMMARY_PART_ADDED,
METHOD_REASONING_SUMMARY_TEXT_DELTA, METHOD_REASONING_TEXT_DELTA,
METHOD_SERVER_REQUEST_RESOLVED, METHOD_SKILLS_CHANGED, METHOD_THREAD_CLOSED,
METHOD_THREAD_GOAL_CLEARED, METHOD_THREAD_GOAL_UPDATED, METHOD_THREAD_NAME_UPDATED,
METHOD_THREAD_STATUS_CHANGED, METHOD_THREAD_TOKEN_USAGE_UPDATED, METHOD_TURN_PLAN_UPDATED,
METHOD_WARNING,
};
use crate::{JsonRpcNotification, JsonRpcRequest, RequestId};
use schemars::JsonSchema;
@@ -654,6 +657,8 @@ pub enum ServerNotification {
SkillsChanged(SkillsChangedNotification),
#[serde(rename = "mcpServer/oauthLogin/completed")]
McpServerOauthLoginCompleted(McpServerOauthLoginCompletedNotification),
#[serde(rename = "mcpServer/startupStatus/updated")]
McpServerStatusUpdated(McpServerStatusUpdatedNotification),
#[serde(rename = "thread/started")]
ThreadStarted(ThreadStartedNotification),
#[serde(rename = "thread/archived")]
@@ -682,6 +687,8 @@ pub enum ServerNotification {
AgentMessageDelta(AgentMessageDeltaNotification),
#[serde(rename = "item/commandExecution/outputDelta")]
CommandExecutionOutputDelta(CommandExecutionOutputDeltaNotification),
#[serde(rename = "item/commandExecution/terminalInteraction")]
CommandExecutionTerminalInteraction(CommandExecutionTerminalInteractionNotification),
#[serde(rename = "item/fileChange/patchUpdated")]
FileChangePatchUpdated(FileChangePatchUpdatedNotification),
#[serde(rename = "item/plan/delta")]
@@ -722,6 +729,7 @@ impl ServerNotification {
Self::Error(_) => METHOD_ERROR,
Self::SkillsChanged(_) => METHOD_SKILLS_CHANGED,
Self::McpServerOauthLoginCompleted(_) => METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED,
Self::McpServerStatusUpdated(_) => METHOD_MCP_SERVER_STARTUP_STATUS_UPDATED,
Self::ThreadStarted(_) => "thread/started",
Self::ThreadArchived(_) => "thread/archived",
Self::ThreadDeleted(_) => "thread/deleted",
@@ -736,6 +744,9 @@ impl ServerNotification {
Self::ItemCompleted(_) => "item/completed",
Self::AgentMessageDelta(_) => "item/agentMessage/delta",
Self::CommandExecutionOutputDelta(_) => METHOD_COMMAND_EXECUTION_OUTPUT_DELTA,
Self::CommandExecutionTerminalInteraction(_) => {
METHOD_COMMAND_EXECUTION_TERMINAL_INTERACTION
}
Self::FileChangePatchUpdated(_) => METHOD_FILE_CHANGE_PATCH_UPDATED,
Self::PlanDelta(_) => METHOD_PLAN_DELTA,
Self::McpToolCallProgress(_) => METHOD_MCP_TOOL_CALL_PROGRESS,
@@ -776,6 +787,9 @@ impl TryFrom<JsonRpcNotification> for ServerNotification {
METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED => serde_json::from_value(params)
.map(Self::McpServerOauthLoginCompleted)
.map_err(|error| error.to_string()),
METHOD_MCP_SERVER_STARTUP_STATUS_UPDATED => serde_json::from_value(params)
.map(Self::McpServerStatusUpdated)
.map_err(|error| error.to_string()),
"thread/started" => serde_json::from_value(params)
.map(Self::ThreadStarted)
.map_err(|error| error.to_string()),
@@ -818,6 +832,9 @@ impl TryFrom<JsonRpcNotification> for ServerNotification {
METHOD_COMMAND_EXECUTION_OUTPUT_DELTA => serde_json::from_value(params)
.map(Self::CommandExecutionOutputDelta)
.map_err(|error| error.to_string()),
METHOD_COMMAND_EXECUTION_TERMINAL_INTERACTION => serde_json::from_value(params)
.map(Self::CommandExecutionTerminalInteraction)
.map_err(|error| error.to_string()),
METHOD_FILE_CHANGE_PATCH_UPDATED => serde_json::from_value(params)
.map(Self::FileChangePatchUpdated)
.map_err(|error| error.to_string()),
@@ -882,6 +899,9 @@ impl From<ServerNotification> for JsonRpcNotification {
ServerNotification::McpServerOauthLoginCompleted(params) => {
jsonrpc_notification(METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED, params)
}
ServerNotification::McpServerStatusUpdated(params) => {
jsonrpc_notification(METHOD_MCP_SERVER_STARTUP_STATUS_UPDATED, params)
}
ServerNotification::ThreadStarted(params) => {
jsonrpc_notification("thread/started", params)
}
@@ -920,6 +940,9 @@ impl From<ServerNotification> for JsonRpcNotification {
ServerNotification::CommandExecutionOutputDelta(params) => {
jsonrpc_notification(METHOD_COMMAND_EXECUTION_OUTPUT_DELTA, params)
}
ServerNotification::CommandExecutionTerminalInteraction(params) => {
jsonrpc_notification(METHOD_COMMAND_EXECUTION_TERMINAL_INTERACTION, params)
}
ServerNotification::FileChangePatchUpdated(params) => {
jsonrpc_notification(METHOD_FILE_CHANGE_PATCH_UPDATED, params)
}
@@ -58,6 +58,8 @@ pub enum ThreadItem {
exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
duration_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
terminal_interactions: Vec<CommandExecutionTerminalInteraction>,
},
FileChange {
id: String,
@@ -136,6 +138,18 @@ pub enum ThreadItem {
ContextCompaction {
id: String,
},
UnknownItem {
id: String,
upstream_type: String,
field_names: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CommandExecutionTerminalInteraction {
pub process_id: String,
pub stdin: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
@@ -411,6 +425,16 @@ pub struct CommandExecutionOutputDeltaNotification {
pub delta: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CommandExecutionTerminalInteractionNotification {
pub thread_id: String,
pub turn_id: String,
pub item_id: String,
pub process_id: String,
pub stdin: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FileChangePatchUpdatedNotification {
@@ -39,10 +39,61 @@ pub struct McpServerOauthLoginCompletedNotification {
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub enum McpServerStartupFailureReason {
ReauthenticationRequired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub enum McpServerStartupState {
Starting,
Ready,
Failed,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct McpServerStatusUpdatedNotification {
#[schemars(required, schema_with = "nullable_string_schema")]
#[serde(deserialize_with = "deserialize_required_nullable_string")]
pub thread_id: Option<String>,
pub name: String,
pub status: McpServerStartupState,
#[schemars(required, schema_with = "nullable_string_schema")]
#[serde(deserialize_with = "deserialize_required_nullable_string")]
pub error: Option<String>,
#[schemars(required, schema_with = "nullable_failure_reason_schema")]
#[serde(deserialize_with = "deserialize_required_nullable_failure_reason")]
pub failure_reason: Option<McpServerStartupFailureReason>,
}
fn nullable_string_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
generator.subschema_for::<Option<String>>()
}
fn nullable_failure_reason_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
generator.subschema_for::<Option<McpServerStartupFailureReason>>()
}
fn deserialize_required_nullable_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer)
}
fn deserialize_required_nullable_failure_reason<'de, D>(
deserializer: D,
) -> Result<Option<McpServerStartupFailureReason>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<McpServerStartupFailureReason>::deserialize(deserializer)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct McpServerElicitationRequestParams {
@@ -296,4 +347,57 @@ mod tests {
assert!(thread_id_types.iter().any(|value| value == "null"));
assert!(thread_id_types.iter().any(|value| value == "string"));
}
#[test]
fn startup_status_updated_matches_codex_wire_and_fails_closed() {
let failed = McpServerStatusUpdatedNotification {
thread_id: None,
name: "remote-docs".to_string(),
status: McpServerStartupState::Failed,
error: Some("scope rejected".to_string()),
failure_reason: Some(McpServerStartupFailureReason::ReauthenticationRequired),
};
assert_eq!(
serde_json::to_value(failed).expect("serialize startup status"),
json!({
"threadId": null,
"name": "remote-docs",
"status": "failed",
"error": "scope rejected",
"failureReason": "reauthenticationRequired"
})
);
let valid = json!({
"threadId": null,
"name": "remote-docs",
"status": "ready",
"error": null,
"failureReason": null
});
for field in ["threadId", "name", "status", "error", "failureReason"] {
let mut missing = valid.clone();
missing
.as_object_mut()
.expect("startup status object")
.remove(field);
assert!(
serde_json::from_value::<McpServerStatusUpdatedNotification>(missing).is_err(),
"missing {field} must fail closed"
);
}
let mut unknown = valid;
unknown["serverName"] = json!("legacy-name");
assert!(serde_json::from_value::<McpServerStatusUpdatedNotification>(unknown).is_err());
let schema =
serde_json::to_value(schemars::schema_for!(McpServerStatusUpdatedNotification))
.expect("serialize startup status schema");
let required = schema["required"]
.as_array()
.expect("startup status schema required fields");
for field in ["threadId", "name", "status", "error", "failureReason"] {
assert!(required.iter().any(|required| required == field));
}
}
}
@@ -53,6 +53,8 @@ pub const METHOD_ITEM_STARTED: &str = "item/started";
pub const METHOD_ITEM_COMPLETED: &str = "item/completed";
pub const METHOD_AGENT_MESSAGE_DELTA: &str = "item/agentMessage/delta";
pub const METHOD_COMMAND_EXECUTION_OUTPUT_DELTA: &str = "item/commandExecution/outputDelta";
pub const METHOD_COMMAND_EXECUTION_TERMINAL_INTERACTION: &str =
"item/commandExecution/terminalInteraction";
pub const METHOD_FILE_CHANGE_PATCH_UPDATED: &str = "item/fileChange/patchUpdated";
pub const METHOD_PLAN_DELTA: &str = "item/plan/delta";
pub const METHOD_MCP_TOOL_CALL_PROGRESS: &str = "item/mcpToolCall/progress";
@@ -72,6 +74,7 @@ pub const METHOD_WARNING: &str = "warning";
pub const METHOD_ERROR: &str = "error";
pub const METHOD_SKILLS_CHANGED: &str = "skills/changed";
pub const METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED: &str = "mcpServer/oauthLogin/completed";
pub const METHOD_MCP_SERVER_STARTUP_STATUS_UPDATED: &str = "mcpServer/startupStatus/updated";
pub const METHOD_CURRENT_TIME_READ: &str = "currentTime/read";
pub const METHOD_MCP_SERVER_ELICITATION_REQUEST: &str = "mcpServer/elicitation/request";
pub const METHOD_ITEM_COMMAND_EXECUTION_REQUEST_APPROVAL: &str =
@@ -10,6 +10,9 @@ pub const V2_SCHEMA_TYPE_NAMES: &[&str] = &[
"ErrorNotification",
"SkillsChangedNotification",
"McpServerOauthLoginCompletedNotification",
"McpServerStartupFailureReason",
"McpServerStartupState",
"McpServerStatusUpdatedNotification",
"CurrentTimeReadParams",
"CurrentTimeReadResponse",
"AdditionalNetworkPermissions",
@@ -85,6 +88,7 @@ pub const V2_SCHEMA_TYPE_NAMES: &[&str] = &[
"CommandAction",
"CommandExecutionSource",
"CommandExecutionStatus",
"CommandExecutionTerminalInteraction",
"FileUpdateChange",
"PatchChangeKind",
"PatchApplyStatus",
@@ -209,6 +213,7 @@ pub const V2_SCHEMA_TYPE_NAMES: &[&str] = &[
"ItemCompletedNotification",
"AgentMessageDeltaNotification",
"CommandExecutionOutputDeltaNotification",
"CommandExecutionTerminalInteractionNotification",
"FileChangePatchUpdatedNotification",
"PlanDeltaNotification",
"McpToolCallProgressNotification",
@@ -40,6 +40,18 @@ fn v2_display_items_match_codex_tagged_nullable_wire() {
let decoded: ThreadItem = serde_json::from_value(mcp.clone()).expect("MCP item");
assert_eq!(serde_json::to_value(decoded).expect("MCP wire"), mcp);
let unknown = json!({
"type": "unknownItem",
"id": "unknown-1",
"upstreamType": "futureCapability",
"fieldNames": ["[redacted]", "label", "opaquePayload"]
});
let decoded: ThreadItem = serde_json::from_value(unknown.clone()).expect("unknown item");
assert_eq!(
serde_json::to_value(decoded).expect("unknown wire"),
unknown
);
for content_item in [
json!({"type": "inputText", "text": "ok"}),
json!({"type": "inputImage", "imageUrl": "https://example.test/a.png"}),
@@ -2272,6 +2284,7 @@ fn typed_v2_envelope_schema_names_are_stable() {
"error",
"skills/changed",
"mcpServer/oauthLogin/completed",
"mcpServer/startupStatus/updated",
"thread/started",
"thread/archived",
"thread/deleted",
@@ -2286,6 +2299,7 @@ fn typed_v2_envelope_schema_names_are_stable() {
"item/completed",
"item/agentMessage/delta",
"item/commandExecution/outputDelta",
"item/commandExecution/terminalInteraction",
"item/fileChange/patchUpdated",
"item/plan/delta",
"item/mcpToolCall/progress",
@@ -753,6 +753,11 @@ pub(super) fn v2_schemas() -> Vec<GeneratedJsonSchema> {
typed_schema::<v2::McpServerOauthLoginCompletedNotification>(
"McpServerOauthLoginCompletedNotification",
),
typed_schema::<v2::McpServerStartupFailureReason>("McpServerStartupFailureReason"),
typed_schema::<v2::McpServerStartupState>("McpServerStartupState"),
typed_schema::<v2::McpServerStatusUpdatedNotification>(
"McpServerStatusUpdatedNotification",
),
typed_schema::<v2::CurrentTimeReadParams>("CurrentTimeReadParams"),
typed_schema::<v2::CurrentTimeReadResponse>("CurrentTimeReadResponse"),
typed_schema::<v2::AdditionalNetworkPermissions>("AdditionalNetworkPermissions"),
@@ -838,6 +843,9 @@ pub(super) fn v2_schemas() -> Vec<GeneratedJsonSchema> {
typed_schema::<v2::CommandAction>("CommandAction"),
typed_schema::<v2::CommandExecutionSource>("CommandExecutionSource"),
typed_schema::<v2::CommandExecutionStatus>("CommandExecutionStatus"),
typed_schema::<v2::CommandExecutionTerminalInteraction>(
"CommandExecutionTerminalInteraction",
),
typed_schema::<v2::FileUpdateChange>("FileUpdateChange"),
typed_schema::<v2::PatchChangeKind>("PatchChangeKind"),
typed_schema::<v2::PatchApplyStatus>("PatchApplyStatus"),
@@ -988,6 +996,9 @@ pub(super) fn v2_schemas() -> Vec<GeneratedJsonSchema> {
typed_schema::<v2::CommandExecutionOutputDeltaNotification>(
"CommandExecutionOutputDeltaNotification",
),
typed_schema::<v2::CommandExecutionTerminalInteractionNotification>(
"CommandExecutionTerminalInteractionNotification",
),
typed_schema::<v2::FileChangePatchUpdatedNotification>(
"FileChangePatchUpdatedNotification",
),
+43 -7
View File
@@ -2,7 +2,8 @@
use super::{dispatch_result, parse_params, to_jsonrpc_error, RequestProcessor, RpcDispatch};
use app_server_protocol::protocol::v2::{
McpServerOauthLoginCompletedNotification, ServerNotification,
McpServerOauthLoginCompletedNotification, McpServerStartupState,
McpServerStatusUpdatedNotification, ServerNotification,
};
use app_server_protocol::{
JsonRpcError, McpPromptGetParams, McpResourceReadParams, McpResourceSubscribeParams,
@@ -124,12 +125,47 @@ impl RequestProcessor {
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: McpServerStartParams = parse_params(params)?;
let response = self
.runtime
.start_mcp_server(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
let server_name = params.name.clone();
self.publish_server_notification(ServerNotification::McpServerStatusUpdated(
McpServerStatusUpdatedNotification {
thread_id: None,
name: server_name.clone(),
status: McpServerStartupState::Starting,
error: None,
failure_reason: None,
},
))
.await;
match self.runtime.start_mcp_server(params).await {
Ok(response) => {
self.publish_server_notification(ServerNotification::McpServerStatusUpdated(
McpServerStatusUpdatedNotification {
thread_id: None,
name: server_name,
status: McpServerStartupState::Ready,
error: None,
failure_reason: None,
},
))
.await;
dispatch_result(response)
}
Err(error) => {
let error_message = error.to_string();
self.publish_server_notification(ServerNotification::McpServerStatusUpdated(
McpServerStatusUpdatedNotification {
thread_id: None,
name: server_name,
status: McpServerStartupState::Failed,
error: Some(error_message),
failure_reason: None,
},
))
.await;
Err(to_jsonrpc_error(error))
}
}
}
pub(super) async fn handle_mcp_server_stop_impl(
@@ -16,30 +16,44 @@ use async_trait::async_trait;
use serde_json::json;
#[derive(Default)]
struct OAuthTestDataSource {
struct McpNotificationTestDataSource {
login: std::sync::Mutex<Option<lime_mcp::McpOAuthLoginHandle>>,
start_result: std::sync::Mutex<Option<Result<(), String>>>,
}
impl crate::SessionAppDataSource for OAuthTestDataSource {}
impl crate::WorkspaceAppDataSource for OAuthTestDataSource {}
impl crate::SkillAppDataSource for OAuthTestDataSource {}
impl crate::WorkspaceSkillBindingAppDataSource for OAuthTestDataSource {}
impl crate::GatewayAppDataSource for OAuthTestDataSource {}
impl crate::MediaAppDataSource for OAuthTestDataSource {}
impl crate::VoiceAppDataSource for OAuthTestDataSource {}
impl crate::PluginDataSource for OAuthTestDataSource {}
impl crate::KnowledgeAppDataSource for OAuthTestDataSource {}
impl crate::AutomationOverviewAppDataSource for OAuthTestDataSource {}
impl crate::AutomationManagementAppDataSource for OAuthTestDataSource {}
impl crate::MemoryAppDataSource for OAuthTestDataSource {}
impl crate::DiagnosticsAppDataSource for OAuthTestDataSource {}
impl crate::UsageStatsAppDataSource for OAuthTestDataSource {}
impl crate::ModelProviderAppDataSource for OAuthTestDataSource {}
impl crate::ConnectAppDataSource for OAuthTestDataSource {}
impl crate::RightSurfaceAppDataSource for OAuthTestDataSource {}
impl crate::SessionAppDataSource for McpNotificationTestDataSource {}
impl crate::WorkspaceAppDataSource for McpNotificationTestDataSource {}
impl crate::SkillAppDataSource for McpNotificationTestDataSource {}
impl crate::WorkspaceSkillBindingAppDataSource for McpNotificationTestDataSource {}
impl crate::GatewayAppDataSource for McpNotificationTestDataSource {}
impl crate::MediaAppDataSource for McpNotificationTestDataSource {}
impl crate::VoiceAppDataSource for McpNotificationTestDataSource {}
impl crate::PluginDataSource for McpNotificationTestDataSource {}
impl crate::KnowledgeAppDataSource for McpNotificationTestDataSource {}
impl crate::AutomationOverviewAppDataSource for McpNotificationTestDataSource {}
impl crate::AutomationManagementAppDataSource for McpNotificationTestDataSource {}
impl crate::MemoryAppDataSource for McpNotificationTestDataSource {}
impl crate::DiagnosticsAppDataSource for McpNotificationTestDataSource {}
impl crate::UsageStatsAppDataSource for McpNotificationTestDataSource {}
impl crate::ModelProviderAppDataSource for McpNotificationTestDataSource {}
impl crate::ConnectAppDataSource for McpNotificationTestDataSource {}
impl crate::RightSurfaceAppDataSource for McpNotificationTestDataSource {}
#[async_trait]
impl crate::McpAppDataSource for OAuthTestDataSource {
impl crate::McpAppDataSource for McpNotificationTestDataSource {
async fn start_mcp_server(
&self,
_params: app_server_protocol::McpServerStartParams,
) -> Result<app_server_protocol::McpServerLifecycleResponse, RuntimeCoreError> {
self.start_result
.lock()
.expect("MCP test start mutex poisoned")
.take()
.ok_or_else(|| RuntimeCoreError::Backend("MCP test start result missing".to_string()))?
.map(|()| app_server_protocol::McpServerLifecycleResponse::default())
.map_err(RuntimeCoreError::Backend)
}
async fn login_mcp_server_oauth(
&self,
_params: app_server_protocol::McpServerOauthLoginParams,
@@ -53,7 +67,7 @@ impl crate::McpAppDataSource for OAuthTestDataSource {
}
fn oauth_test_data_source() -> (
Arc<OAuthTestDataSource>,
Arc<McpNotificationTestDataSource>,
tokio::sync::oneshot::Sender<Result<(), String>>,
) {
let (completion_tx, completion_rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
@@ -68,13 +82,37 @@ fn oauth_test_data_source() -> (
},
);
(
Arc::new(OAuthTestDataSource {
Arc::new(McpNotificationTestDataSource {
login: std::sync::Mutex::new(Some(handle)),
start_result: std::sync::Mutex::new(None),
}),
completion_tx,
)
}
async fn start_mcp_notification_test(
start_result: Result<(), String>,
) -> (
RequestProcessor,
tokio::sync::mpsc::UnboundedReceiver<app_server_protocol::protocol::v2::ServerNotification>,
) {
let data_source = Arc::new(McpNotificationTestDataSource {
login: std::sync::Mutex::new(None),
start_result: std::sync::Mutex::new(Some(start_result)),
});
let runtime = RuntimeCore::default().with_app_data_source(data_source);
let (notification_tx, notification_rx) = tokio::sync::mpsc::unbounded_channel();
let hook: ServerNotificationHook = Arc::new(move |notification| {
let notification_tx = notification_tx.clone();
Box::pin(async move {
let _ = notification_tx.send(notification);
})
});
let processor = RequestProcessor::new(runtime).with_server_notification_hook(hook);
initialize_processor(&processor).await;
(processor, notification_rx)
}
async fn start_oauth_test() -> (
RequestProcessor,
tokio::sync::oneshot::Sender<Result<(), String>>,
@@ -314,6 +352,95 @@ async fn mcp_runtime_methods_require_initialized_and_fail_closed_without_manager
}
}
#[tokio::test]
async fn mcp_start_publishes_starting_then_ready_before_success_response() {
let (processor, mut notification_rx) = start_mcp_notification_test(Ok(())).await;
let messages = processor
.handle_request(JsonRpcRequest::new(
RequestId::Integer(28),
METHOD_MCP_SERVER_START,
Some(json!({ "name": "remote-docs" })),
))
.await
.expect("MCP start response");
assert!(matches!(messages.as_slice(), [JsonRpcMessage::Response(_)]));
let starting = notification_rx.recv().await.expect("starting notification");
let ready = notification_rx.recv().await.expect("ready notification");
assert_eq!(
starting,
app_server_protocol::protocol::v2::ServerNotification::McpServerStatusUpdated(
app_server_protocol::protocol::v2::McpServerStatusUpdatedNotification {
thread_id: None,
name: "remote-docs".to_string(),
status: app_server_protocol::protocol::v2::McpServerStartupState::Starting,
error: None,
failure_reason: None,
}
)
);
assert_eq!(
ready,
app_server_protocol::protocol::v2::ServerNotification::McpServerStatusUpdated(
app_server_protocol::protocol::v2::McpServerStatusUpdatedNotification {
thread_id: None,
name: "remote-docs".to_string(),
status: app_server_protocol::protocol::v2::McpServerStartupState::Ready,
error: None,
failure_reason: None,
}
)
);
assert!(notification_rx.try_recv().is_err());
}
#[tokio::test]
async fn mcp_start_publishes_starting_then_failed_before_error_response() {
let (processor, mut notification_rx) =
start_mcp_notification_test(Err("handshake rejected".to_string())).await;
let messages = processor
.handle_request(JsonRpcRequest::new(
RequestId::Integer(29),
METHOD_MCP_SERVER_START,
Some(json!({ "name": "remote-docs" })),
))
.await
.expect("MCP start error response");
assert!(matches!(
messages.as_slice(),
[JsonRpcMessage::Error(error)] if error.error.code == error_codes::RUNTIME_ERROR
));
let starting = notification_rx.recv().await.expect("starting notification");
let failed = notification_rx.recv().await.expect("failed notification");
let app_server_protocol::protocol::v2::ServerNotification::McpServerStatusUpdated(starting) =
starting
else {
panic!("expected starting status notification");
};
assert_eq!(
starting.status,
app_server_protocol::protocol::v2::McpServerStartupState::Starting
);
let app_server_protocol::protocol::v2::ServerNotification::McpServerStatusUpdated(failed) =
failed
else {
panic!("expected failed status notification");
};
assert_eq!(
failed.status,
app_server_protocol::protocol::v2::McpServerStartupState::Failed
);
assert_eq!(failed.thread_id, None);
assert_eq!(failed.name, "remote-docs");
assert!(failed
.error
.as_deref()
.is_some_and(|error| error.contains("handshake rejected")));
assert_eq!(failed.failure_reason, None);
assert!(notification_rx.try_recv().is_err());
}
#[tokio::test]
async fn mcp_oauth_login_returns_before_typed_success_notification() {
let (processor, completion_tx, mut notification_rx) = start_oauth_test().await;
@@ -483,6 +483,7 @@ fn project_item(item: canonical::ThreadItem) -> Result<v2::ThreadItem, JsonRpcEr
exit_code,
duration_ms: metadata_u64(&metadata, &["durationMs", "duration_ms"])
.map(saturating_i64),
terminal_interactions: project_terminal_interactions(&metadata),
}),
canonical::ThreadItemPayload::File { changes, status } => Ok(v2::ThreadItem::FileChange {
id,
@@ -518,6 +519,14 @@ fn project_item(item: canonical::ThreadItem) -> Result<v2::ThreadItem, JsonRpcEr
canonical::ThreadItemPayload::ContextCompaction { .. } => {
Ok(v2::ThreadItem::ContextCompaction { id })
}
canonical::ThreadItemPayload::Unknown {
upstream_type,
field_names,
} => Ok(v2::ThreadItem::UnknownItem {
id,
upstream_type,
field_names,
}),
canonical::ThreadItemPayload::Extension { name, .. } => Err(projection_error(format!(
"canonical extension item {id} ({name}) has no v2 ThreadItem representation"
))),
@@ -554,6 +563,25 @@ fn project_command_source(metadata: &Value) -> v2::CommandExecutionSource {
}
}
fn project_terminal_interactions(metadata: &Value) -> Vec<v2::CommandExecutionTerminalInteraction> {
metadata
.get("terminalInteractions")
.or_else(|| metadata.get("terminal_interactions"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|interaction| {
let process_id = metadata_string(interaction, &["processId", "process_id"])?;
let stdin = metadata_string(interaction, &["stdin"])?;
Some(v2::CommandExecutionTerminalInteraction {
process_id,
stdin: bounded_safe_text(&stdin, MAX_DISPLAY_STRING_BYTES).0,
})
})
.take(20)
.collect()
}
fn thread_matches_list_filters(thread: &canonical::Thread, params: &v2::ThreadListParams) -> bool {
if thread.archived != params.archived.unwrap_or(false) {
return false;
@@ -565,6 +565,41 @@ fn unsupported_canonical_item_fails_closed() {
assert!(error.message.contains("no v2 ThreadItem representation"));
}
#[test]
fn canonical_unknown_item_projects_typed_v2_diagnostic() {
let mut thread = canonical_thread(false);
thread.turns[0].items[0].kind = canonical::ItemKind::Unknown;
thread.turns[0].items[0].status = canonical::ItemStatus::Completed;
thread.turns[0].items[0].payload = canonical::ThreadItemPayload::Unknown {
upstream_type: "futureCapability".to_string(),
field_names: vec![
"[redacted]".to_string(),
"label".to_string(),
"opaquePayload".to_string(),
],
};
let projected = project_thread(thread).expect("project typed unknown item");
let v2::ThreadItem::UnknownItem {
id,
upstream_type,
field_names,
} = &projected.turns[0].items[0]
else {
panic!("typed unknown item");
};
assert_eq!(id, "item_message-1");
assert_eq!(upstream_type, "futureCapability");
assert_eq!(
field_names,
&vec![
"[redacted]".to_string(),
"label".to_string(),
"opaquePayload".to_string(),
]
);
}
#[test]
fn approval_control_items_stay_out_of_codex_v2_thread_items() {
let mut thread = canonical_thread(false);
@@ -95,6 +95,13 @@ impl V2NotificationProjector {
event,
)
}
"command.interaction" => {
return command::project_terminal_interaction(
&self.started_command_item_ids,
&self.completed_command_item_ids,
event,
)
}
"patch.started" => {
return file_change::project_started(
&mut self.started_file_change_item_ids,
@@ -1154,6 +1161,44 @@ mod tests {
);
}
#[test]
fn maps_terminal_interaction_to_typed_notification_for_active_command() {
let mut projector = V2NotificationProjector::default();
projector
.project(event(
"command.started",
json!({"item": canonical_command_item("inProgress")}),
))
.expect("command started");
let interaction = projector
.project(event(
"command.interaction",
json!({
"commandId": "shell-1",
"processId": "process-1",
"stdin": "sent 8 chars"
}),
))
.expect("terminal interaction");
assert_eq!(interaction.len(), 1);
assert_eq!(
interaction[0].method,
"item/commandExecution/terminalInteraction"
);
assert_eq!(
interaction[0].params.as_ref().expect("interaction params"),
&json!({
"threadId": "thread-1",
"turnId": "turn-1",
"itemId": "shell-1",
"processId": "process-1",
"stdin": "sent 8 chars"
})
);
}
#[test]
fn maps_file_change_to_started_patch_updated_and_completed() {
let mut projector = V2NotificationProjector::default();
@@ -38,6 +38,70 @@ pub(super) fn project_output_delta(
.into()])
}
pub(super) fn project_terminal_interaction(
started_item_ids: &HashSet<String>,
completed_item_ids: &HashSet<String>,
event: &AgentEvent,
) -> EventProjection {
let Some(thread_id) = required_event_id(event.thread_id.as_deref()) else {
return EventProjection::Reject(projection_error(event));
};
let Some(turn_id) = required_event_id(event.turn_id.as_deref()) else {
return EventProjection::Reject(projection_error(event));
};
let Some(item_id) = event
.payload
.get("commandId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string)
else {
return EventProjection::Reject(projection_error(event));
};
let Some(process_id) = event
.payload
.get("processId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
else {
return EventProjection::Reject(projection_error(event));
};
if !started_item_ids.contains(&item_id) || completed_item_ids.contains(&item_id) {
return EventProjection::Reject(projection_error(event));
}
let Some(stdin) = event
.payload
.get("stdin")
.and_then(serde_json::Value::as_str)
.filter(|value| terminal_interaction_summary_is_safe(value))
else {
return EventProjection::Reject(projection_error(event));
};
EventProjection::Direct(vec![
ServerNotification::CommandExecutionTerminalInteraction(
v2::CommandExecutionTerminalInteractionNotification {
thread_id,
turn_id,
item_id,
process_id: process_id.to_string(),
stdin: stdin.to_string(),
},
)
.into(),
])
}
fn terminal_interaction_summary_is_safe(value: &str) -> bool {
if matches!(value, "(poll)" | "(interrupt)") {
return true;
}
value
.strip_prefix("sent ")
.and_then(|value| value.strip_suffix(" chars"))
.is_some_and(|count| !count.is_empty() && count.bytes().all(|byte| byte.is_ascii_digit()))
}
fn command_projection(event: &AgentEvent) -> Option<(String, String, String)> {
let thread_id = required_event_id(event.thread_id.as_deref())?;
let turn_id = required_event_id(event.turn_id.as_deref())?;
@@ -935,7 +935,9 @@ fn validate_agent_control_fork_items(turn: &agent_protocol::Turn) -> Result<(),
}
}
ThreadItemPayload::AgentMessage { .. } => {}
ThreadItemPayload::ContextCompaction { .. } | ThreadItemPayload::Extension { .. } => {}
ThreadItemPayload::ContextCompaction { .. }
| ThreadItemPayload::Unknown { .. }
| ThreadItemPayload::Extension { .. } => {}
}
}
Ok(())
@@ -271,4 +271,54 @@ mod tests {
assert_eq!(turn["items"][0]["payload"]["phase"], "final_answer");
assert_eq!(turn["items"][0]["payload"]["text"], "final answer");
}
#[test]
fn unknown_item_notification_attaches_sanitized_canonical_entity() {
let event = AgentEvent {
event_id: "event-unknown-started".to_string(),
sequence: 1,
session_id: "session-1".to_string(),
thread_id: Some("thread-1".to_string()),
turn_id: Some("turn-1".to_string()),
event_type: "item.started".to_string(),
timestamp: "2026-07-13T00:00:01Z".to_string(),
payload: json!({"item": {
"id": "unknown-1",
"type": "futureCapability",
"label": "future capability",
"secretToken": "UNKNOWN_ITEM_SECRET_MUST_NOT_LEAK",
"status": "inProgress"
}}),
};
let notifications =
notification_events_with_canonical_entities(&stored_session(), &[event])
.expect("unknown item canonical notification");
let item = &notifications[0].payload["item"];
assert_eq!(item["kind"], "unknown");
assert_eq!(item["payload"]["type"], "unknown");
assert_eq!(item["payload"]["upstream_type"], "futureCapability");
assert_eq!(
item["payload"]["field_names"],
json!(["[redacted]", "label", "status"])
);
let wire = item.to_string();
assert!(!wire.contains("UNKNOWN_ITEM_SECRET_MUST_NOT_LEAK"));
assert!(!wire.contains("future capability"));
}
#[test]
fn malformed_unknown_item_notification_fails_closed() {
let mut malformed = event("item.started");
malformed.payload = json!({"item": {
"id": "unknown-1",
"type": "future_capability"
}});
let error = notification_events_with_canonical_entities(&stored_session(), &[malformed])
.expect_err("malformed unknown item must fail closed");
assert!(error
.to_string()
.contains("canonical materializer produced no item"));
}
}
@@ -93,6 +93,7 @@ where
ThreadItemPayload::CollabAgentToolCall { .. }
| ThreadItemPayload::Media { .. }
| ThreadItemPayload::ContextCompaction { .. }
| ThreadItemPayload::Unknown { .. }
| ThreadItemPayload::Extension { .. } => {
return Err(format!(
"fork canonical item {} cannot be lowered without loss",
@@ -323,6 +323,14 @@ fn canonical_payload_to_agent_detail(
}
"context_compaction"
}
ThreadItemPayload::Unknown {
upstream_type,
field_names,
} => {
detail.insert("upstream_type".to_string(), json!(upstream_type));
detail.insert("field_names".to_string(), json!(field_names));
"unknown_item"
}
ThreadItemPayload::Extension { name, data } => {
detail.insert("extension_name".to_string(), json!(name));
detail.insert("extension_data".to_string(), data.clone());
@@ -382,6 +382,35 @@ fn canonical_approval_detail_uses_typed_terminal_response() {
assert!(pending.get("response").is_none());
}
#[test]
fn canonical_unknown_item_detail_contains_no_raw_values() {
let item = ThreadItem {
session_id: SessionId::new("session-unknown-read"),
thread_id: ThreadId::new("thread-unknown-read"),
turn_id: TurnId::new("turn-unknown-read"),
item_id: ItemId::new("unknown-read"),
sequence: 3,
ordinal: 2,
created_at_ms: 1,
updated_at_ms: 2,
completed_at_ms: Some(2),
kind: agent_protocol::ItemKind::Unknown,
status: ItemStatus::Completed,
payload: ThreadItemPayload::Unknown {
upstream_type: "futureCapability".to_string(),
field_names: vec!["[redacted]".to_string(), "label".to_string()],
},
metadata: serde_json::Value::Null,
};
let detail = canonical_item_to_agent_detail(&item);
assert_eq!(detail["type"], "unknown_item");
assert_eq!(detail["upstream_type"], "futureCapability");
assert_eq!(detail["field_names"], json!(["[redacted]", "label"]));
assert_eq!(detail["metadata"], serde_json::Value::Null);
assert!(!detail.to_string().contains("raw"));
}
#[test]
fn canonical_wait_collab_tool_projects_as_completed_tool_call() {
let item = ThreadItem {
@@ -616,9 +616,9 @@ fn validate_fork_canonical_item(item: &ThreadItem) -> Result<(), RuntimeCoreErro
)
})?;
}
ThreadItemPayload::Extension { .. } => {
ThreadItemPayload::Unknown { .. } | ThreadItemPayload::Extension { .. } => {
return Err(invalid(
"thread/fork cannot preserve extension provider history from canonical history",
"thread/fork cannot preserve unknown or extension provider history from canonical history",
));
}
_ => {}
@@ -208,6 +208,7 @@ pub(in crate::runtime) fn merge_item_snapshot(
previous: ThreadItem,
mut next: ThreadItem,
) -> ThreadItem {
let previous_terminal_interactions = previous.metadata.get("terminalInteractions").cloned();
let source_event_type = next
.metadata
.get("source_event_type")
@@ -222,6 +223,29 @@ pub(in crate::runtime) fn merge_item_snapshot(
next.status = previous.status;
}
next.payload = merge_payload(previous.payload, next.payload, source_event_type.as_deref());
if matches!(
next.payload,
agent_protocol::ThreadItemPayload::Command { .. }
) {
if let Some(interactions) = previous_terminal_interactions {
if let Some(metadata) = next.metadata.as_object_mut() {
let next_interactions = metadata
.get("terminalInteractions")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
let mut merged = interactions.as_array().cloned().unwrap_or_default();
merged.extend(next_interactions);
if merged.len() > 20 {
merged.drain(..merged.len() - 20);
}
metadata.insert(
"terminalInteractions".to_string(),
serde_json::Value::Array(merged),
);
}
}
}
next
}
@@ -16,7 +16,7 @@ use self::lifecycle::{
event_timestamp_ms, item_status, parse_timestamp_ms, queued_turn_id, rollback_target,
turn_approval_state, turn_queue_state,
};
use self::lowering::{item_family, typed_payload, ItemFamily};
use self::lowering::{item_family, typed_payload, unknown_payload_is_safe, ItemFamily};
use super::change_set::{ChangeSetAccumulator, MaterializationError};
use super::helpers::event_metadata;
use agent_protocol::{
@@ -267,6 +267,9 @@ fn materialized_item_metadata(
source: &serde_json::Map<String, Value>,
family: ItemFamily,
) -> Value {
if matches!(family, ItemFamily::Unknown) {
return Value::Null;
}
let mut metadata = event_metadata(event)
.as_object()
.cloned()
@@ -278,6 +281,18 @@ fn materialized_item_metadata(
if let Some(command_id) = map_string(source, &["commandId", "command_id"]) {
metadata.insert("source_call_id".to_string(), Value::String(command_id));
}
if event.event_type == "command.interaction" {
if let (Some(process_id), Some(stdin)) = (
map_string(source, &["processId", "process_id"]),
map_string(source, &["stdin"])
.filter(|value| terminal_interaction_summary_is_safe(value)),
) {
metadata.insert(
"terminalInteractions".to_string(),
serde_json::json!([{"processId": process_id, "stdin": stdin}]),
);
}
}
}
Value::Object(metadata)
}
@@ -325,11 +340,25 @@ fn canonical_item_from_event(
Some(item)
}
fn terminal_interaction_summary_is_safe(value: &str) -> bool {
if matches!(value, "(poll)" | "(interrupt)") {
return true;
}
value
.strip_prefix("sent ")
.and_then(|value| value.strip_suffix(" chars"))
.is_some_and(|count| !count.is_empty() && count.bytes().all(|byte| byte.is_ascii_digit()))
}
fn canonical_payload_is_safe(payload: &ThreadItemPayload) -> bool {
match payload {
ThreadItemPayload::AgentMessage { content_parts, .. } => content_parts
.iter()
.all(agent_protocol::MessageContentPart::is_safe),
ThreadItemPayload::Unknown {
upstream_type,
field_names,
} => unknown_payload_is_safe(upstream_type, field_names),
_ => true,
}
}
@@ -9,6 +9,10 @@ use agent_protocol::{
SubAgentActivityKind, ThreadId, ThreadItemPayload, ToolOutput,
};
use serde_json::{Map, Value};
use std::collections::BTreeSet;
const UNKNOWN_ITEM_FIELD_NAME_LIMIT: usize = 12;
const UNKNOWN_ITEM_REDACTED_FIELD_NAME: &str = "[redacted]";
#[derive(Clone, Copy)]
pub(super) enum ItemFamily {
@@ -25,6 +29,7 @@ pub(super) enum ItemFamily {
Media,
SubAgent,
ContextCompaction,
Unknown,
}
impl ItemFamily {
@@ -61,6 +66,7 @@ impl ItemFamily {
Self::Media => "media",
Self::SubAgent => "subagent",
Self::ContextCompaction => "compaction",
Self::Unknown => "unknown",
}
}
@@ -70,6 +76,9 @@ impl ItemFamily {
turn_id: &str,
event_id: &str,
) -> Option<String> {
if matches!(self, Self::Unknown) {
return explicit_item_id(payload);
}
if matches!(self, Self::Plan) {
if let Some(item_id) = explicit_item_id(payload) {
return Some(item_id);
@@ -97,8 +106,8 @@ pub(super) fn item_family(event_type: &str, payload: &Value) -> Option<ItemFamil
let normalized = event_type.to_ascii_lowercase();
if normalized.starts_with("item.") {
let source = payload_source(payload);
let kind =
map_string(source, &["kind", "type", "itemType", "item_type"])?.to_ascii_lowercase();
let upstream_type = map_string(source, &["kind", "type", "itemType", "item_type"])?;
let kind = upstream_type.to_ascii_lowercase();
return match kind.as_str() {
"user_message" | "usermessage" | "user" => Some(ItemFamily::UserMessage),
"agent_message" | "agentmessage" | "assistant" | "message" => {
@@ -117,6 +126,13 @@ pub(super) fn item_family(event_type: &str, payload: &Value) -> Option<ItemFamil
"media" | "artifact" | "image" | "video" | "audio" => Some(ItemFamily::Media),
"subagent" | "sub_agent" | "subagent_activity" => Some(ItemFamily::SubAgent),
"context_compaction" | "compaction" => Some(ItemFamily::ContextCompaction),
_ if matches!(
normalized.as_str(),
"item.started" | "item.updated" | "item.completed"
) && is_canonical_unknown_item_type(&upstream_type) =>
{
Some(ItemFamily::Unknown)
}
_ => None,
};
}
@@ -369,9 +385,105 @@ pub(super) fn typed_payload(
window_id: map_string(payload, &["windowId", "window_id", "contextWindowId"]),
tail_start_turn_id: map_string(payload, &["tailStartTurnId", "tail_start_turn_id"]),
},
ItemFamily::Unknown => ThreadItemPayload::Unknown {
upstream_type: unknown_item_upstream_type(payload)?,
field_names: unknown_item_field_names(payload),
},
})
}
fn unknown_item_upstream_type(payload: &Map<String, Value>) -> Option<String> {
let upstream_type = map_string(payload, &["kind", "type", "itemType", "item_type"])?;
is_canonical_unknown_item_type(&upstream_type).then_some(upstream_type)
}
fn is_canonical_unknown_item_type(value: &str) -> bool {
let mut chars = value.chars();
chars.next().is_some_and(|first| first.is_ascii_lowercase())
&& chars.all(|character| character.is_ascii_alphanumeric())
}
pub(super) fn unknown_payload_is_safe(upstream_type: &str, field_names: &[String]) -> bool {
if !is_canonical_unknown_item_type(upstream_type)
|| field_names.len() > UNKNOWN_ITEM_FIELD_NAME_LIMIT
{
return false;
}
let sanitized = field_names.iter().cloned().collect::<BTreeSet<_>>();
sanitized.len() == field_names.len()
&& sanitized.iter().eq(field_names.iter())
&& field_names.iter().all(|name| {
name == UNKNOWN_ITEM_REDACTED_FIELD_NAME
|| (is_safe_unknown_item_field_name(name)
&& !is_sensitive_unknown_item_field_name(name))
})
}
fn unknown_item_field_names(payload: &Map<String, Value>) -> Vec<String> {
payload
.keys()
.filter(|name| !is_unknown_item_identity_field(name))
.map(|name| sanitize_unknown_item_field_name(name))
.collect::<BTreeSet<_>>()
.into_iter()
.take(UNKNOWN_ITEM_FIELD_NAME_LIMIT)
.collect()
}
fn is_unknown_item_identity_field(name: &str) -> bool {
matches!(
name,
"id" | "itemId"
| "item_id"
| "kind"
| "type"
| "itemType"
| "item_type"
| "sessionId"
| "session_id"
| "threadId"
| "thread_id"
| "turnId"
| "turn_id"
)
}
fn sanitize_unknown_item_field_name(name: &str) -> String {
if is_safe_unknown_item_field_name(name) && !is_sensitive_unknown_item_field_name(name) {
name.to_string()
} else {
UNKNOWN_ITEM_REDACTED_FIELD_NAME.to_string()
}
}
fn is_safe_unknown_item_field_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_alphabetic()
&& name.len() <= 64
&& chars
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
}
fn is_sensitive_unknown_item_field_name(name: &str) -> bool {
let normalized = name.to_ascii_lowercase();
[
"authorization",
"cookie",
"credential",
"password",
"secret",
"token",
]
.iter()
.any(|sensitive| normalized.contains(sensitive))
|| normalized.contains("apikey")
|| normalized.contains("api_key")
|| normalized.contains("api-key")
}
fn user_message_content(payload: &Map<String, Value>) -> Option<Vec<AgentInput>> {
let content = match payload.get("input") {
Some(input) => serde_json::from_value::<Vec<AgentInput>>(input.clone()).ok()?,
@@ -669,6 +669,59 @@ fn user_message_snapshot_does_not_restore_empty_content_from_a_previous_item() {
);
}
#[test]
fn command_interactions_survive_terminal_snapshot_and_keep_the_latest_twenty() {
let mut events = vec![event(
"command-started",
1,
"command.started",
"turn-1",
json!({
"commandId": "command-1",
"command": "read input",
"cwd": "/workspace"
}),
)];
events.extend((0..21).map(|index| {
event(
&format!("command-interaction-{index}"),
index + 2,
"command.interaction",
"turn-1",
json!({
"commandId": "command-1",
"processId": "unified-exec-1000",
"stdin": format!("sent {index} chars")
}),
)
}));
events.push(event(
"command-completed",
23,
"command.completed",
"turn-1",
json!({
"commandId": "command-1",
"command": "read input",
"cwd": "/workspace",
"status": "completed",
"exitCode": 0
}),
));
let changes = materialize_events(&events, "session-1", "thread-1")
.expect("materialize command interactions");
assert_eq!(changes.changed_items.len(), 1);
let interactions = changes.changed_items[0]
.metadata
.get("terminalInteractions")
.and_then(serde_json::Value::as_array)
.expect("terminal interactions");
assert_eq!(interactions.len(), 20);
assert_eq!(interactions[0]["stdin"], json!("sent 1 chars"));
assert_eq!(interactions[19]["stdin"], json!("sent 20 chars"));
}
#[test]
fn canonical_display_payloads_merge_create_update_and_terminal_fields() {
let changes = materialize_events(
@@ -55,6 +55,121 @@ fn canonical_nested_item_uses_outer_event_identity_time_and_lifecycle_status() {
assert_eq!(failed.metadata, json!({"source": "nested-canonical"}));
}
#[test]
fn unknown_item_lifecycle_retains_only_sanitized_field_names() {
let changes = materialize_events(
&[
event(
"unknown-start",
1,
"item.started",
"turn-unknown",
json!({"item": {
"id": "unknown-1",
"threadId": "thread-1",
"turnId": "turn-unknown",
"type": "futureCapability",
"label": "future capability",
"opaquePayload": "opaque-value-must-not-survive",
"secretToken": "UNKNOWN_ITEM_SECRET_MUST_NOT_LEAK",
"status": "inProgress"
}}),
),
event(
"unknown-completed",
2,
"item.completed",
"turn-unknown",
json!({"item": {
"id": "unknown-1",
"threadId": "thread-1",
"turnId": "turn-unknown",
"type": "futureCapability",
"label": "future capability",
"opaquePayload": "opaque-value-must-not-survive",
"secretToken": "UNKNOWN_ITEM_SECRET_MUST_NOT_LEAK",
"status": "completed"
}}),
),
],
"session-1",
"thread-1",
)
.expect("materialize unknown item lifecycle");
assert_eq!(changes.changed_items.len(), 1);
let item = &changes.changed_items[0];
assert_eq!(item.kind, ItemKind::Unknown);
assert_eq!(item.status, ItemStatus::Completed);
assert_eq!(item.item_id.as_str(), "item_unknown-1");
assert_eq!(item.metadata, serde_json::Value::Null);
assert_eq!(
item.payload,
ThreadItemPayload::Unknown {
upstream_type: "futureCapability".to_string(),
field_names: vec![
"[redacted]".to_string(),
"label".to_string(),
"opaquePayload".to_string(),
"status".to_string(),
],
}
);
let wire = serde_json::to_string(item).expect("serialize unknown item");
assert!(!wire.contains("UNKNOWN_ITEM_SECRET_MUST_NOT_LEAK"));
assert!(!wire.contains("opaque-value-must-not-survive"));
assert!(!wire.contains("future capability"));
}
#[test]
fn malformed_unknown_item_lifecycle_fails_closed() {
for payload in [
json!({"item": {"type": "futureCapability", "label": "missing id"}}),
json!({"item": {"id": "unknown-1", "label": "missing type"}}),
json!({"item": {"id": "unknown-1", "type": "FutureCapability"}}),
json!({"item": {"id": "unknown-1", "type": "future_capability"}}),
] {
let changes = materialize_events(
&[event(
"malformed-unknown",
1,
"item.started",
"turn-unknown",
payload,
)],
"session-1",
"thread-1",
)
.expect("malformed unknown item does not create canonical state");
assert!(changes.changed_items.is_empty());
}
}
#[test]
fn unsafe_nested_canonical_unknown_item_fails_closed() {
let mut nested = canonical_tool_item("unknown-nested", "completed", 1);
nested["kind"] = json!("unknown");
nested["payload"] = json!({
"type": "unknown",
"upstream_type": "futureCapability",
"field_names": ["secretToken"]
});
let changes = materialize_events(
&[event(
"unsafe-nested-unknown",
1,
"item.started",
"turn-unknown",
json!({"item": nested}),
)],
"session-1",
"thread-1",
)
.expect("unsafe nested unknown does not create canonical state");
assert!(changes.changed_items.is_empty());
}
#[test]
fn canonical_nested_tool_output_and_metadata_are_preserved_across_lifecycle() {
let mut started = canonical_tool_item("nested-tool", "pending", 7);
@@ -1,5 +1,5 @@
use crate::RuntimeEvent;
use agent_protocol::{ItemStatus, ThreadItem, ThreadItemPayload, ToolArgument, ToolOutput};
use agent_protocol::{ItemId, ItemStatus, ThreadItem, ThreadItemPayload, ToolArgument, ToolOutput};
use lime_agent::{AgentEvent as RuntimeAgentEvent, AgentToolResult};
use serde_json::{json, Value};
use std::collections::HashMap;
@@ -65,14 +65,20 @@ impl CodingEventMirror {
),
..CodingMirrorEvents::default()
},
RuntimeAgentEvent::ItemCompleted { item } => canonical_tool_item(item)
.and_then(|tool| {
tool.output.map(|output| {
let result = canonical_agent_tool_result(item, output);
self.handle_tool_end(tool.call_id, &result)
RuntimeAgentEvent::ItemCompleted { item } => {
let mut events = canonical_tool_item(item)
.and_then(|tool| {
tool.output.map(|output| {
let result = canonical_agent_tool_result(item, output);
self.handle_tool_end(tool.call_id, &result)
})
})
})
.unwrap_or_default(),
.unwrap_or_default();
if let Some(interaction) = terminal_interaction_event(item) {
events.before_raw.push(interaction);
}
events
}
_ => CodingMirrorEvents::default(),
}
}
@@ -297,6 +303,45 @@ impl CodingEventMirror {
}
}
fn terminal_interaction_event(item: &ThreadItem) -> Option<RuntimeEvent> {
let metadata = item.metadata.as_object()?;
let interaction = metadata.get("terminal_interaction")?.as_object()?;
let process_id = interaction.get("process_id")?.as_str()?;
let stdin = interaction
.get("stdin")?
.as_str()
.filter(|value| terminal_interaction_summary_is_safe(value))?;
let command_id = match &item.payload {
ThreadItemPayload::Command { .. } => item.item_id.as_str().to_string(),
ThreadItemPayload::Tool { .. } => {
ItemId::new(metadata.get("exec_command_call_id")?.as_str()?.to_string())
.as_str()
.to_string()
}
_ => return None,
};
let mut payload = json!({
"commandId": command_id,
"processId": process_id,
"stdin": stdin,
"source": "unified_exec",
});
if let ThreadItemPayload::Tool { call_id, .. } = &item.payload {
payload["toolCallId"] = Value::String(call_id.clone());
}
Some(RuntimeEvent::new("command.interaction", payload))
}
fn terminal_interaction_summary_is_safe(value: &str) -> bool {
if matches!(value, "(poll)" | "(interrupt)") {
return true;
}
value
.strip_prefix("sent ")
.and_then(|value| value.strip_suffix(" chars"))
.is_some_and(|count| !count.is_empty() && count.bytes().all(|byte| byte.is_ascii_digit()))
}
struct CanonicalToolItem<'a> {
call_id: &'a str,
name: &'a str,
@@ -199,6 +199,51 @@ fn shell_tool_output_delta_preserves_process_lifecycle_metadata() {
);
}
#[test]
fn canonical_command_completion_emits_redacted_terminal_interaction_before_raw_item() {
let mut mirror = CodingEventMirror::default();
let payload = ThreadItemPayload::Command {
command: "printf ready".to_string(),
cwd: Some("/workspace".to_string()),
output: Some("ready".to_string()),
exit_code: Some(0),
};
let item = ThreadItem {
session_id: SessionId::new("session-test"),
thread_id: ThreadId::new("thread-test"),
turn_id: TurnId::new("turn-test"),
item_id: ItemId::new("exec-call"),
sequence: 2,
ordinal: 1,
created_at_ms: 1,
updated_at_ms: 2,
completed_at_ms: Some(2),
kind: payload.kind(),
status: ItemStatus::Completed,
payload,
metadata: json!({
"exec_command_call_id": "exec-call",
"terminal_interaction": {
"process_id": "process-7",
"stdin": "sent 9 chars"
}
}),
};
let output = mirror.process_event(&RuntimeAgentEvent::ItemCompleted { item });
assert_eq!(output.before_raw.len(), 1);
assert!(output.after_raw.is_empty());
assert_eq!(output.before_raw[0].event_type, "command.interaction");
assert_eq!(output.before_raw[0].payload["commandId"], "item_exec-call");
assert_eq!(output.before_raw[0].payload["processId"], "process-7");
assert_eq!(output.before_raw[0].payload["stdin"], "sent 9 chars");
assert!(!output.before_raw[0]
.payload
.to_string()
.contains("continue"));
}
#[test]
fn shell_tool_metadata_only_delta_updates_process_lifecycle_without_consuming_output() {
let mut mirror = CodingEventMirror::default();
+1 -1
View File
@@ -13,7 +13,7 @@ pub trait EventEmit: Send + Sync + 'static {
/// 发送事件
///
/// # Arguments
/// * `event` - 事件名称(如 "mcp:server_started"
/// * `event` - 事件名称(如 "mcp:tools_updated"
/// * `payload` - JSON 格式的事件数据
fn emit_event(&self, event: &str, payload: &serde_json::Value) -> Result<(), String>;
}
+1 -21
View File
@@ -1,28 +1,8 @@
//! MCP 事件 Payload。
use crate::types::{McpServerCapabilities, McpToolDefinition};
use crate::types::McpToolDefinition;
use serde::Serialize;
/// 服务器启动事件
#[derive(Debug, Clone, Serialize)]
pub struct McpServerStartedPayload {
pub server_name: String,
pub server_info: Option<McpServerCapabilities>,
}
/// 服务器停止事件
#[derive(Debug, Clone, Serialize)]
pub struct McpServerStoppedPayload {
pub server_name: String,
}
/// 服务器错误事件
#[derive(Debug, Clone, Serialize)]
pub struct McpServerErrorPayload {
pub server_name: String,
pub error: String,
}
/// 工具列表更新事件
#[derive(Debug, Clone, Serialize)]
pub struct McpToolsUpdatedPayload {
+1 -4
View File
@@ -30,10 +30,7 @@ pub use elicitation::{
ClaimedElicitationResolution, ElicitationAction, ElicitationRequest, ElicitationRequestId,
ElicitationRequestRouter, ElicitationResponse, ElicitationRouterError,
};
pub use events::{
McpResourceUpdatedPayload, McpResourcesUpdatedPayload, McpServerErrorPayload,
McpServerStartedPayload, McpServerStoppedPayload, McpToolsUpdatedPayload,
};
pub use events::{McpResourceUpdatedPayload, McpResourcesUpdatedPayload, McpToolsUpdatedPayload};
pub use extension_surface::{build_runtime_extension_surface, runtime_extension_name};
pub use manager::{McpBridgeSnapshot, McpClientManager, McpRuntimeOwner, McpRuntimeServerSpec};
pub use oauth::{McpOAuthLoginHandle, McpOAuthLoginParams, McpOAuthRegistry};
+2 -42
View File
@@ -98,10 +98,9 @@ pub struct McpClientManager {
/// 事件发射器
///
/// 用于向前端发送 MCP 相关事件,如:
/// - mcp:server_started
/// - mcp:server_stopped
/// - mcp:server_error
/// - mcp:tools_updated
/// - mcp:resources_updated
/// - mcp:resource_updated
emitter: Option<DynEmitter>,
oauth_registry: McpOAuthRegistry,
@@ -537,45 +536,6 @@ impl McpClientManager {
}
}
/// 发送服务器启动事件
pub fn emit_server_started(
&self,
server_name: &str,
server_info: Option<McpServerCapabilities>,
) {
info!(server_name = %server_name, "MCP 服务器已启动");
self.emit_event(
"mcp:server_started",
McpServerStartedPayload {
server_name: server_name.to_string(),
server_info,
},
);
}
/// 发送服务器停止事件
pub fn emit_server_stopped(&self, server_name: &str) {
info!(server_name = %server_name, "MCP 服务器已停止");
self.emit_event(
"mcp:server_stopped",
McpServerStoppedPayload {
server_name: server_name.to_string(),
},
);
}
/// 发送服务器错误事件
pub fn emit_server_error(&self, server_name: &str, error: &str) {
warn!(server_name = %server_name, error = %error, "MCP 服务器错误");
self.emit_event(
"mcp:server_error",
McpServerErrorPayload {
server_name: server_name.to_string(),
error: error.to_string(),
},
);
}
/// 发送工具列表更新事件
pub fn emit_tools_updated(&self, tools: Vec<McpToolDefinition>) {
debug!(tool_count = tools.len(), "工具列表已更新");
@@ -32,7 +32,6 @@ impl McpClientManager {
/// 3. 建立 MCP 连接
/// 4. 初始化 MCP 客户端
/// 5. 失效工具缓存
/// 6. 发送 mcp:server_started 事件
pub async fn start_server(&self, name: &str, config: &McpServerConfig) -> Result<(), McpError> {
config.validate().map_err(McpError::ConfigError)?;
if !config.enabled {
@@ -75,7 +74,6 @@ impl McpClientManager {
Err(e) => {
let error_msg = format!("无法启动服务器进程: {e}");
error!(server_name = %name, error = %e, "启动 MCP 服务器进程失败");
self.emit_server_error(name, &error_msg);
return Err(McpError::ProcessSpawnFailed(error_msg));
}
};
@@ -100,7 +98,6 @@ impl McpClientManager {
}
let error_msg = format!("MCP 连接失败: {e}");
error!(server_name = %name, error = %e, "MCP 客户端初始化失败");
self.emit_server_error(name, &error_msg);
return Err(McpError::ConnectionFailed(error_msg));
}
Err(_) => {
@@ -108,9 +105,7 @@ impl McpClientManager {
if let Some(task) = stderr_task.take() {
task.abort();
}
let error_msg = format!("MCP 连接超时({timeout_secs}秒)");
error!(server_name = %name, timeout = timeout_secs, "MCP 连接超时");
self.emit_server_error(name, &error_msg);
return Err(McpError::Timeout);
}
};
@@ -138,9 +133,6 @@ impl McpClientManager {
// 5. 失效工具缓存
self.invalidate_tool_cache().await;
// 6. 发送 mcp:server_started 事件
self.emit_server_started(name, server_info);
info!(server_name = %name, "MCP 服务器启动成功");
Ok(())
}
@@ -202,13 +194,10 @@ impl McpClientManager {
Ok(Err(error)) => {
let error_msg = format!("MCP streamable HTTP 连接失败: {error}");
error!(server_name = %name, url = %url, error = %error, "MCP HTTP 客户端初始化失败");
self.emit_server_error(name, &error_msg);
return Err(McpError::ConnectionFailed(error_msg));
}
Err(_) => {
let error_msg = format!("MCP streamable HTTP 连接超时({timeout_secs}秒)");
error!(server_name = %name, url = %url, timeout = timeout_secs, "MCP HTTP 连接超时");
self.emit_server_error(name, &error_msg);
return Err(McpError::Timeout);
}
};
@@ -227,8 +216,6 @@ impl McpClientManager {
wrapper.set_running_service(running_service);
self.add_client(name.to_string(), wrapper).await?;
self.invalidate_tool_cache().await;
self.emit_server_started(name, server_info);
info!(server_name = %name, url = %url, "MCP streamable HTTP 服务器启动成功");
Ok(())
}
@@ -277,7 +264,6 @@ impl McpClientManager {
/// 2. 终止子进程
/// 3. 清理客户端连接
/// 4. 失效工具缓存
/// 5. 发送 mcp:server_stopped 事件
pub async fn stop_server(&self, name: &str) -> Result<(), McpError> {
info!(server_name = %name, "停止 MCP 服务器");
@@ -303,9 +289,6 @@ impl McpClientManager {
// 4. 失效工具缓存
self.invalidate_tool_cache().await;
// 5. 发送 mcp:server_stopped 事件
self.emit_server_stopped(name);
info!(server_name = %name, "MCP 服务器已停止");
Ok(())
}
@@ -328,6 +328,7 @@ async fn write_stdin(
unified_exec_error(format!("write_stdin arguments are invalid: {error}"))
})?;
let session = find_session(input.session_id)?;
let process_id = session_facts(&session).await.process_id;
{
let session = session.lock().await;
if session.thread_id != request.thread_id {
@@ -343,6 +344,7 @@ async fn write_stdin(
})
.map_err(unified_exec_error)?;
}
let stdin_summary = terminal_interaction_summary(&input.chars);
let output = collect_process_output(
gateway,
input.session_id,
@@ -352,7 +354,22 @@ async fn write_stdin(
request.cancel_token,
)
.await?;
Ok(project_output(output))
let mut result = project_output(output);
result.metadata.insert(
"terminal_interaction".to_string(),
json!({"stdin": stdin_summary, "process_id": process_id}),
);
Ok(result)
}
fn terminal_interaction_summary(chars: &str) -> String {
if chars.is_empty() {
return "(poll)".to_string();
}
if chars == "\u{3}" {
return "(interrupt)".to_string();
}
format!("sent {} chars", chars.chars().count())
}
async fn collect_process_output(
@@ -366,6 +366,13 @@ async fn write_stdin_resumes_and_completes_original_exec_command() {
completed.metadata.get("exec_command_call_id"),
Some(&json!("call-long"))
);
assert_eq!(
completed.metadata.get("terminal_interaction"),
Some(&json!({
"process_id": format!("unified-exec-{}", session_id),
"stdin": "sent 9 chars",
}))
);
}
#[tokio::test]
+3 -1
View File
@@ -5,7 +5,7 @@
"workspaces": [
"packages/*"
],
"version": "1.118.0",
"version": "1.119.0",
"description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.",
"keywords": [
"ai",
@@ -221,6 +221,7 @@
"smoke:local-history-import-real-sample-visual-audit": "node scripts/electron/local-history-import-real-sample-visual-audit-smoke.mjs",
"smoke:mcp-config-electron-fixture": "node scripts/electron/mcp-config-fixture-smoke.mjs",
"smoke:mcp-oauth-notification-electron-fixture": "npm run build:renderer:electron && node scripts/electron/mcp-oauth-notification-fixture-smoke.mjs",
"smoke:mcp-startup-notification-electron-fixture": "npm run build:renderer:electron && node scripts/electron/mcp-startup-notification-fixture-smoke.mjs",
"smoke:settings-about-electron-fixture": "node scripts/electron/settings-about-fixture-smoke.mjs",
"smoke:settings-stats-electron-fixture": "node scripts/electron/settings-stats-fixture-smoke.mjs",
"smoke:settings-environment-electron-fixture": "node scripts/electron/settings-environment-fixture-smoke.mjs",
@@ -244,6 +245,7 @@
"smoke:plugin-runtime-electron-fixture": "node scripts/plugin/runtime-electron-fixture-smoke.mjs",
"smoke:plugin-runtime-task-electron-fixture": "node scripts/plugin/runtime-electron-task-fixture-smoke.mjs",
"smoke:claw-chat-current-fixture": "node scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs",
"smoke:unknown-item-recovery-electron-fixture": "node scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs --scenario unknown-item --prefix claw-chat-current-fixture-unknown-item-regression --timeout-ms 240000",
"smoke:claw-image-live": "node scripts/agent-runtime/claw-image-live-smoke.mjs",
"smoke:claw-chat-ready-streaming": "node scripts/claw-chat-ready-streaming-smoke.mjs",
"smoke:at-command-registry": "node scripts/at-command-registry-e2e.mjs",
@@ -55,6 +55,7 @@ type ItemStreamingNotification = Extract<
method:
| "item/agentMessage/delta"
| "item/commandExecution/outputDelta"
| "item/commandExecution/terminalInteraction"
| "item/fileChange/patchUpdated"
| "item/mcpToolCall/progress"
| "item/plan/delta"
@@ -155,6 +156,7 @@ function isItemStreamingNotification(
return (
method === "item/agentMessage/delta" ||
method === "item/commandExecution/outputDelta" ||
method === "item/commandExecution/terminalInteraction" ||
method === "item/fileChange/patchUpdated" ||
method === "item/mcpToolCall/progress" ||
method === "item/plan/delta" ||
@@ -575,6 +575,7 @@ test("item streaming notifications cross the pipeline without synthesizing verif
const notifications = [
agentMessageDeltaNotification(),
commandOutputDeltaNotification(),
commandTerminalInteractionNotification(),
fileChangePatchUpdatedNotification(),
mcpToolCallProgressNotification(),
planDeltaNotification(),
@@ -597,7 +598,7 @@ test("item streaming notifications cross the pipeline without synthesizing verif
assert.deepEqual(
processed.map((result) => result.accepted),
[true, true, true, true, true, true, true, true],
[true, true, true, true, true, true, true, true, true],
);
assert.deepEqual(
processed.map((result) => result.notification.method),
@@ -874,6 +875,20 @@ function commandOutputDeltaNotification(overrides = {}) {
};
}
function commandTerminalInteractionNotification(overrides = {}) {
return {
method: "item/commandExecution/terminalInteraction",
params: {
itemId: "command-1",
processId: "unified-exec-1000",
stdin: "sent 9 chars",
threadId: "thread-1",
turnId: "turn-1",
...overrides,
},
};
}
function fileChangePatchUpdatedNotification(overrides = {}) {
return {
method: "item/fileChange/patchUpdated",
@@ -40,6 +40,7 @@ export type AgentRuntimeLifecycleNotification = Extract<
| "item/completed"
| "item/agentMessage/delta"
| "item/commandExecution/outputDelta"
| "item/commandExecution/terminalInteraction"
| "item/fileChange/patchUpdated"
| "item/mcpToolCall/progress"
| "item/plan/delta"
@@ -4750,6 +4750,19 @@ export type CommandExecutionStatus =
| "failed"
| "inProgress";
export interface CommandExecutionTerminalInteraction {
processId: string;
stdin: string;
}
export interface CommandExecutionTerminalInteractionNotification {
itemId: string;
processId: string;
stdin: string;
threadId: string;
turnId: string;
}
export interface ConfigWarningNotification {
details?: null | string;
path?: null | string;
@@ -6036,10 +6049,26 @@ export interface McpServerStartParams {
name: string;
}
export type McpServerStartupFailureReason = "reauthenticationRequired";
export type McpServerStartupState =
| "cancelled"
| "failed"
| "ready"
| "starting";
export interface McpServerStatusListResponse {
servers?: unknown[];
}
export interface McpServerStatusUpdatedNotification {
error: null | string;
failureReason: McpServerStartupFailureReason | null;
name: string;
status: McpServerStartupState;
threadId: null | string;
}
export interface McpServerStopParams {
name: string;
}
@@ -7811,6 +7840,10 @@ export type ServerNotification =
method: "mcpServer/oauthLogin/completed";
params: McpServerOauthLoginCompletedNotification;
}
| {
method: "mcpServer/startupStatus/updated";
params: McpServerStatusUpdatedNotification;
}
| {
method: "thread/started";
params: ThreadStartedNotification;
@@ -7867,6 +7900,10 @@ export type ServerNotification =
method: "item/commandExecution/outputDelta";
params: CommandExecutionOutputDeltaNotification;
}
| {
method: "item/commandExecution/terminalInteraction";
params: CommandExecutionTerminalInteractionNotification;
}
| {
method: "item/fileChange/patchUpdated";
params: FileChangePatchUpdatedNotification;
@@ -8671,6 +8708,7 @@ export type ThreadItem =
processId?: null | string;
source?: CommandExecutionSource;
status: CommandExecutionStatus;
terminalInteractions?: CommandExecutionTerminalInteraction[];
type: "commandExecution";
}
| {
@@ -8750,6 +8788,12 @@ export type ThreadItem =
| {
id: string;
type: "contextCompaction";
}
| {
fieldNames: string[];
id: string;
type: "unknownItem";
upstreamType: string;
};
export interface ThreadItemEntry {
@@ -17,6 +17,7 @@ export type RuntimeServerNotification = Extract<
| "item/completed"
| "item/agentMessage/delta"
| "item/commandExecution/outputDelta"
| "item/commandExecution/terminalInteraction"
| "item/fileChange/patchUpdated"
| "item/mcpToolCall/progress"
| "item/plan/delta"
@@ -42,6 +43,11 @@ export type McpServerOauthLoginCompletedServerNotification = Extract<
{ method: "mcpServer/oauthLogin/completed" }
>;
export type McpServerStatusUpdatedServerNotification = Extract<
ServerNotification,
{ method: "mcpServer/startupStatus/updated" }
>;
export type ServerNotificationFor<
Method extends RuntimeServerNotification["method"],
> = Extract<RuntimeServerNotification, { method: Method }>;
@@ -94,6 +100,10 @@ export function serverNotification(
return hasItemTextDelta(message.params)
? (message as ServerNotificationFor<"item/commandExecution/outputDelta">)
: undefined;
case "item/commandExecution/terminalInteraction":
return hasTerminalInteraction(message.params)
? (message as ServerNotificationFor<"item/commandExecution/terminalInteraction">)
: undefined;
case "item/fileChange/patchUpdated":
return hasFileChangePatchUpdated(message.params)
? (message as ServerNotificationFor<"item/fileChange/patchUpdated">)
@@ -207,6 +217,41 @@ export function mcpServerOauthLoginCompletedServerNotification(
return message as McpServerOauthLoginCompletedServerNotification;
}
export function mcpServerStatusUpdatedServerNotification(
message: JsonRpcMessage,
): McpServerStatusUpdatedServerNotification | undefined {
if (
!isJsonRpcNotification(message) ||
message.method !== "mcpServer/startupStatus/updated"
) {
return undefined;
}
const params = record(message.params);
const status = params?.status;
const failureReason = params?.failureReason;
if (
!hasOnlyKeys(params, [
"error",
"failureReason",
"name",
"status",
"threadId",
]) ||
!hasRequiredNullableString(params, "threadId") ||
!hasString(params, "name") ||
(status !== "starting" &&
status !== "ready" &&
status !== "failed" &&
status !== "cancelled") ||
!hasRequiredNullableString(params, "error") ||
!Object.prototype.hasOwnProperty.call(params, "failureReason") ||
(failureReason !== null && failureReason !== "reauthenticationRequired")
) {
return undefined;
}
return message as McpServerStatusUpdatedServerNotification;
}
export function isThreadStartedNotification(
message: JsonRpcMessage,
): message is ServerNotificationFor<"thread/started"> {
@@ -263,6 +308,15 @@ export function isCommandExecutionOutputDeltaNotification(
);
}
export function isCommandExecutionTerminalInteractionNotification(
message: JsonRpcMessage,
): message is ServerNotificationFor<"item/commandExecution/terminalInteraction"> {
return (
serverNotification(message)?.method ===
"item/commandExecution/terminalInteraction"
);
}
export function isFileChangePatchUpdatedNotification(
message: JsonRpcMessage,
): message is ServerNotificationFor<"item/fileChange/patchUpdated"> {
@@ -471,6 +525,32 @@ function hasItemTextDelta(value: unknown): boolean {
);
}
function hasTerminalInteraction(value: unknown): boolean {
const params = record(value);
return (
hasOnlyKeys(params, [
"itemId",
"processId",
"stdin",
"threadId",
"turnId",
]) &&
hasString(params, "threadId") &&
hasString(params, "turnId") &&
hasString(params, "itemId") &&
hasString(params, "processId") &&
isTerminalInteractionSummary(params?.stdin)
);
}
function isTerminalInteractionSummary(value: unknown): value is string {
return (
value === "(poll)" ||
value === "(interrupt)" ||
(typeof value === "string" && /^sent [0-9]+ chars$/u.test(value))
);
}
function hasFileChangePatchUpdated(value: unknown): boolean {
const params = record(value);
return (
@@ -4659,6 +4659,26 @@ test("routes direct lifecycle notifications without wrapper projection", async (
]);
});
test("routes terminal interaction notifications through the lifecycle router", async () => {
const notification = {
method: "item/commandExecution/terminalInteraction",
params: {
itemId: "command-1",
processId: "unified-exec-1000",
stdin: "sent 9 chars",
threadId: "thread_external",
turnId: "turn_external",
},
};
const lifecycle = [];
const router = new AppServerAgentEventRouter();
router.subscribeLifecycle((event) => lifecycle.push(event));
assert.equal(agentRuntimeLifecycleNotification(notification), notification);
assert.equal(await router.dispatch(notification), true);
assert.deepEqual(lifecycle, [notification]);
});
test("routes typed error notifications through the signal router", async () => {
const notification = {
method: "error",
@@ -3,6 +3,7 @@ import { test } from "vitest";
import {
isAgentMessageDeltaNotification,
isCommandExecutionOutputDeltaNotification,
isCommandExecutionTerminalInteractionNotification,
isErrorNotification,
isFileChangePatchUpdatedNotification,
isItemCompletedNotification,
@@ -22,6 +23,7 @@ import {
isTurnStartedNotification,
modelListUpdatedServerNotification,
mcpServerOauthLoginCompletedServerNotification,
mcpServerStatusUpdatedServerNotification,
serverNotification,
skillsChangedServerNotification,
} from "../dist/index.js";
@@ -112,6 +114,90 @@ test("recognizes strict mcpServer/oauthLogin/completed notifications", () => {
}
});
test("recognizes strict mcpServer/startupStatus/updated notifications", () => {
const ready = {
method: "mcpServer/startupStatus/updated",
params: {
threadId: null,
name: "remote-docs",
status: "ready",
error: null,
failureReason: null,
},
};
const failed = {
method: "mcpServer/startupStatus/updated",
params: {
threadId: "thread-1",
name: "remote-docs",
status: "failed",
error: "OAuth credentials expired",
failureReason: "reauthenticationRequired",
},
};
assert.deepEqual(mcpServerStatusUpdatedServerNotification(ready), ready);
assert.deepEqual(mcpServerStatusUpdatedServerNotification(failed), failed);
for (const malformed of [
{ method: "mcpServer/startupStatus/updated" },
{
method: "mcpServer/startupStatus/updated",
params: {
name: "remote-docs",
status: "ready",
error: null,
failureReason: null,
},
},
{
method: "mcpServer/startupStatus/updated",
params: {
threadId: null,
name: "",
status: "ready",
error: null,
failureReason: null,
},
},
{
method: "mcpServer/startupStatus/updated",
params: {
threadId: null,
name: "remote-docs",
status: "stopped",
error: null,
failureReason: null,
},
},
{
method: "mcpServer/startupStatus/updated",
params: {
threadId: null,
name: "remote-docs",
status: "failed",
error: null,
},
},
{
method: "mcpServer/startupStatus/updated",
params: {
threadId: null,
name: "remote-docs",
status: "ready",
error: null,
failureReason: null,
serverName: "legacy",
},
},
]) {
assert.equal(
mcpServerStatusUpdatedServerNotification(malformed),
undefined,
);
}
});
test("recognizes native v2 lifecycle and reasoning notifications", () => {
const notifications = [
{
@@ -185,6 +271,16 @@ test("recognizes native v2 lifecycle and reasoning notifications", () => {
method: "item/commandExecution/outputDelta",
params: { delta: "stdout\n", itemId: "command-1", threadId, turnId },
},
{
method: "item/commandExecution/terminalInteraction",
params: {
itemId: "command-1",
processId: "unified-exec-1000",
stdin: "sent 9 chars",
threadId,
turnId,
},
},
{
method: "item/fileChange/patchUpdated",
params: {
@@ -257,18 +353,22 @@ test("recognizes native v2 lifecycle and reasoning notifications", () => {
isCommandExecutionOutputDeltaNotification(notifications[8]),
true,
);
assert.equal(isFileChangePatchUpdatedNotification(notifications[9]), true);
assert.equal(isPlanDeltaNotification(notifications[10]), true);
assert.equal(isMcpToolCallProgressNotification(notifications[11]), true);
assert.equal(
isReasoningSummaryTextDeltaNotification(notifications[12]),
isCommandExecutionTerminalInteractionNotification(notifications[9]),
true,
);
assert.equal(isFileChangePatchUpdatedNotification(notifications[10]), true);
assert.equal(isPlanDeltaNotification(notifications[11]), true);
assert.equal(isMcpToolCallProgressNotification(notifications[12]), true);
assert.equal(
isReasoningSummaryTextDeltaNotification(notifications[13]),
true,
);
assert.equal(
isReasoningSummaryPartAddedNotification(notifications[13]),
isReasoningSummaryPartAddedNotification(notifications[14]),
true,
);
assert.equal(isReasoningTextDeltaNotification(notifications[14]), true);
assert.equal(isReasoningTextDeltaNotification(notifications[15]), true);
assert.equal(
isThreadSettingsUpdatedNotification({
method: "thread/settings/updated",
@@ -285,6 +385,37 @@ test("recognizes native v2 lifecycle and reasoning notifications", () => {
);
});
test("fails closed for malformed terminal interaction notifications", () => {
for (const params of [
{ itemId: "command-1", processId: "process-1", threadId, turnId },
{
itemId: "command-1",
processId: "process-1",
stdin: "raw-input-must-not-pass",
threadId,
turnId,
},
{
itemId: "command-1",
processId: "process-1",
stdin: "sent 1 chars",
threadId,
turnId,
raw: "x",
},
]) {
const notification = {
method: "item/commandExecution/terminalInteraction",
params,
};
assert.equal(serverNotification(notification), undefined);
assert.equal(
isCommandExecutionTerminalInteractionNotification(notification),
false,
);
}
});
test("fails closed for malformed turn plan updates", () => {
const malformed = [
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@limecloud/lime-cli",
"version": "1.118.0",
"version": "1.119.0",
"description": "Lime 官方任务 CLI",
"bin": {
"lime": "scripts/run.js"
+6
View File
@@ -153,6 +153,8 @@ npm run smoke:mcp-current -- --allow-write-fixture
npm run smoke:mcp-current -- --allow-plugin-runtime-fixture
npm run smoke:mcp-current -- --allow-oauth-fixture
npm run smoke:mcp-config-electron-fixture
npm run smoke:mcp-oauth-notification-electron-fixture
npm run smoke:mcp-startup-notification-electron-fixture
npm run smoke:settings-about-electron-fixture -- --run-id <run-id>
npm run smoke:settings-stats-electron-fixture -- --run-id <run-id>
npm run smoke:settings-environment-electron-fixture -- --run-id <run-id>
@@ -175,6 +177,8 @@ npm run smoke:mcp-elicitation-gate-b
默认入口只通过 `app_server_handle_json_lines -> App Server JSON-RPC` 验证 `mcpServer/list``mcpServerStatus/list``mcpTool/list|listForContext|search``mcpPrompt/list``mcpResource/list` 读链,并禁止旧 `mcp_*` / `get_mcp_servers` Tauri facade 作为成功证据。`--allow-write-fixture` 会创建临时 stdio MCP server,覆盖 `mcpServer/create|start|stop|delete``mcpTool/call``mcpResource/read`,并断言工具 `outputSchema` 暴露 `structuredContent`、调用结果保留 `structuredContent`。同一轮还会启动一个必然失败的 server,要求其 `mcpServer/start` 错误以 JSON-RPC error 穿过 Desktop Host 返回,同时健康 server 继续保持 running,且 tool list/call 与 resource read 均可用。`--allow-plugin-runtime-fixture` 会复用临时 stdio MCP server,覆盖 `agentSession/toolInventory/read``plugin_runtime_capabilities.mcpBindings``plugin_mcp_targets` 的投影、caller-scoped `mcpTool/listForContext` proof、显式 `mcpTool/callWithCaller` proof,并断言无显式 `callProof` 时默认 list proof 不会调用工具。
`--allow-oauth-fixture` 会创建本地 OAuth provider,覆盖 `mcpServer/oauth/login`、Electron `open_external_url` 系统浏览器网关、callback token exchange 与 `runtime_status.auth_status` 授权回流,用于复验动态 OAuth current 链路;该模式不依赖真实外部账号或 live Provider。
`npm run smoke:mcp-oauth-notification-electron-fixture` 验证 OAuth callback completion 的 App Server typed notification 与 GUI 自动刷新;`npm run smoke:mcp-startup-notification-electron-fixture` 验证 MCP startup 的 `starting -> ready``starting -> failed` typed notification、Settings 连接态与终态 status/tool 刷新。两者都启动隔离的真实 Electron Desktop Host、preload/IPC 与 App Server runtime,不调用正式模型或 live Provider,并要求旧 MCP lifecycle Desktop event、renderer mock fallback 与 App Server mock backend 命中为零。
`npm run smoke:mcp-config-electron-fixture -- --run-id <run-id>` 是真实 Electron 设置页配置闭环 fixture:从桌面壳侧栏进入设置页,切到 MCP 配置管理,选择 Context7 preset,编辑 streamable HTTP URL 与 `env_http_headers` 环境变量名并保存,再通过 preload `app_server_handle_json_lines -> mcpServer/create|list` 验证 App Server current read model。显式 run-id 时证据写入 `.lime/qc/project-gates/<run-id>/settings-mcp-create-list/`,只有 Electron、preload、`electron-ipc`、current methods、GUI readback、零 legacy/mock/error 与截图全部成立才输出 `settingsScenarioProof={scenarioId:mcp-create-list,complete:true}`。该入口不启动 Context7、不调用真实 provider、不读取或写入真实 key,不走 App Server mock backend、renderer mock fallback 或旧 `mcp_*` Desktop facade。
`npm run smoke:settings-mcp-lifecycle-electron-fixture -- --run-id <run-id>` 在隔离 userData 中从真实 MCP Settings GUI 创建配置、修改描述与 Lime 启用状态、冷重启读回、从 GUI 删除并再次冷重启确认不存在;要求命中 `mcpServer/list|create|update|delete`,且 Electron、preload、`electron-ipc``app_server_handle_json_lines`、零 legacy/mock/error 与三张终态截图同时成立。该 Gate B-F claim 不启动 live MCP server、不调用工具、不访问配置 URL,也不在 JSON evidence 中保存 server 配置、名称、描述、ID、路径、凭证、prompt、resource 或 tool output。
@@ -314,6 +318,8 @@ Agent Runtime smoke 与 Service Skill 入口 smoke 已迁到 `scripts/agent-runt
`npm run smoke:claw-chat-current-fixture` 是更重的真实 Electron GUI fixture:通过真实输入框发送“整理今天的国际新闻”,验证用户输入可见、assistant 完成态输出可见、输入框不消失、App Server `agentSession/turn/start` 走 current JSON-RPC、WebSearch 不按关键词强制 required,并使用本地 external backend fixture 代替正式模型后端。修 Agent Runtime / Claw 输入、流式卡住、历史 hydrate 或新闻请求链路时,先跑聚合 guard,再按需要显式跑该入口;修无法停止或停止后无法继续输出时,还必须跑 `--scenario cancel-then-continue`,证明同一 current session 停止后能再次从 GUI 输入“继续输出”并完成第二轮。
`npm run smoke:unknown-item-recovery-electron-fixture` 复用同一真实 Electron fixture 注入一个受控 future Item type,验证 `item.started -> item.completed -> turn.completed` 经 preload/IPC、App Server runtime/read model 与 direct TurnTimeline fail-visibleGUI 和 cold read 只保留 upstream type 与脱敏字段名,不允许原始字段值、secret、raw payload 或生产 mock fallback。
`npm run smoke:claw-image-live``@配图` 真实 Provider live 验收入口:默认 fail-closed,必须显式传 `--allow-live-provider` 或设置 `LIME_ALLOW_LIVE_PROVIDER_SMOKE=1 / LIME_REAL_API_TEST=1`。它启动真实 Electron Desktop Host 与 `APP_SERVER_BACKEND_MODE=runtime`,通过 GUI 输入框发送 `@配图`,验证 Agent 普通对话流里的思考 / 引导文字、`Image Generation` 图片任务卡、真实图片预览、Token 显示、右侧 viewer 不自动展开,以及普通 UI 不暴露 task path、workflow 字段、provider 内部字段或模板 task id。传 `--setup-agnes-from-env` 时只从 `AGNES_API_KEY`(或 `--api-key-env` 指定变量)读取 key,summary 仅记录 `apiKeyConfigured: true`,不记录 key 值。该入口还会经 `mediaTaskArtifact/list|get``workflow/read` 与 task audit JSONL 验证后端事实源,确保图片任务只落可审计 JSONL / workflow summary,不靠 mock worker、renderer mock fallback 或右侧 viewer 展示内部 JSON。
新增 Agent Runtime 脚本继续进入 `scripts/agent-runtime/` 或复用现有 Agent Runtime npm scripts;共享实现仍放在 `scripts/lib/`
@@ -66,6 +66,7 @@ import {
ACTIVE_STEER_INITIAL_PROMPT,
ACTIVE_STEER_SCENARIO,
} from "./claw-chat-current-fixture-active-steer.mjs";
import { UNKNOWN_ITEM_SCENARIO } from "./claw-chat-current-fixture-unknown-item.mjs";
export function resolveGateBExpectedIdentity({
summary,
@@ -478,6 +479,7 @@ export function buildAssertionContext({
const isMediaReferenceScenario =
options.scenario === MEDIA_REFERENCE_SCENARIO;
const isSkillsRuntimeScenario = options.scenario === "skills-runtime";
const isUnknownItemScenario = options.scenario === UNKNOWN_ITEM_SCENARIO;
const isSoulStyleScenario = options.scenario === SOUL_STYLE_SCENARIO;
const isExpertPlazaSkillsRuntimeScenario =
options.scenario === "expert-plaza-skills-runtime";
@@ -722,6 +724,7 @@ export function buildAssertionContext({
isMcpStructuredContentScenario,
isMediaReferenceScenario,
isSkillsRuntimeScenario,
isUnknownItemScenario,
isSoulStyleScenario,
isExpertPlazaSkillsRuntimeScenario,
isExpertPanelSkillsRuntimeScenario,
@@ -5,6 +5,7 @@ import {
renderApprovalRequestResumeTurnStartScript,
} from "./claw-chat-current-fixture-approval-backend-events.mjs";
import { renderBackendToolAndSkillEventScript } from "./claw-chat-current-fixture-backend-tool-skill-events.mjs";
import { renderUnknownItemBackendEventsExpression } from "./claw-chat-current-fixture-unknown-item.mjs";
import {
APPROVAL_REQUEST_RESUME_PROMPT,
APPROVAL_REQUEST_FULL_ACCESS_DONE_TEXT,
@@ -833,6 +834,7 @@ ${renderApprovalRequestResumeTurnStartScript()}
: "以下是今日国际新闻简要整理:\\n";
const initialEvents = [
...approvalSessionCacheEvents(),
${renderUnknownItemBackendEventsExpression()}
...(isLiveTailCommitPrompt
? [
{
File diff suppressed because it is too large Load Diff
@@ -89,6 +89,10 @@ import {
import { runTerminalStaleGuardScenario } from "./claw-chat-current-fixture-terminal-stale-guard.mjs";
import { runTypedErrorScenario } from "./claw-chat-current-fixture-typed-error.mjs";
import { runTurnPlanUpdateScenario } from "./claw-chat-current-fixture-turn-plan-update.mjs";
import {
collectUnknownItemScenarioEvidence,
UNKNOWN_ITEM_SCENARIO,
} from "./claw-chat-current-fixture-unknown-item.mjs";
import {
createExpertSkillsRuntimeSession,
openSessionFromSidebar,
@@ -965,6 +969,16 @@ export async function executeScenarioFlow({
: {}),
});
if (options.scenario === UNKNOWN_ITEM_SCENARIO) {
logStage("collect-unknown-item-live-and-recovery-evidence");
summary.unknownItem = sanitizeJson(
await collectUnknownItemScenarioEvidence({
page,
readModel: readModelCompleted,
}),
);
}
if (options.scenario !== SOUL_STYLE_SCENARIO) {
logStage("probe-direct-v2-event-read");
summary.eventReadProbe = await runEventReadProbe(
@@ -85,6 +85,7 @@ import {
} from "./claw-chat-current-fixture-invoke-trace.mjs";
import { summarizeHomeHotpathPreTurnTrace } from "./claw-chat-current-fixture-home-hotpath.mjs";
import { executeScenarioFlow } from "./claw-chat-current-fixture-scenario-flow.mjs";
import { UNKNOWN_ITEM_SCENARIO } from "./claw-chat-current-fixture-unknown-item.mjs";
import {
MEDIA_REFERENCE_PROMPT,
MEDIA_REFERENCE_SCENARIO,
@@ -139,7 +140,7 @@ Claw Chat Current Electron Fixture Smoke
--evidence-dir <path> 证据目录
--prefix <name> 证据文件前缀
--run-id <id> Gate 项目 run-id也可通过 LIME_GATE_RUN_ID 注入
--scenario <name> complete | home-hotpath | home-hotpath-greeting | cancel | cancel-then-continue | inputbar-rich-restore | inputbar-active-steer | plan | turn-plan-update | goal | soul-style | image-command | plain-image-intent | media-reference | reasoning-first-visible | live-tail-commit | electron-resize-reflow | approval-request-resume | approval-request-decline | approval-request-cancel | approval-request-host-interrupt | approval-request-full-access | terminal-failed-after-answer | terminal-canceled-after-answer | terminal-stale-guard | typed-error-retry-success | typed-error-retry-failure | web-tools-rendering | mcp-structured-content | skills-runtime | expert-plaza-skills-runtime | expert-panel-skills-runtime | right-surface-visual-matrix | content-factory-article-workspace | content-factory-inline-image-article-workspace默认 complete
--scenario <name> complete | unknown-item | home-hotpath | home-hotpath-greeting | cancel | cancel-then-continue | inputbar-rich-restore | inputbar-active-steer | plan | turn-plan-update | goal | soul-style | image-command | plain-image-intent | media-reference | reasoning-first-visible | live-tail-commit | electron-resize-reflow | approval-request-resume | approval-request-decline | approval-request-cancel | approval-request-host-interrupt | approval-request-full-access | terminal-failed-after-answer | terminal-canceled-after-answer | terminal-stale-guard | typed-error-retry-success | typed-error-retry-failure | web-tools-rendering | mcp-structured-content | skills-runtime | expert-plaza-skills-runtime | expert-panel-skills-runtime | right-surface-visual-matrix | content-factory-article-workspace | content-factory-inline-image-article-workspace默认 complete
--prompt <text> home-hotpath 场景可用覆盖默认新闻输入
--soul-style-profile <id> soul-style 场景使用的 profile默认 ${DEFAULT_SOUL_STYLE_FIXTURE_PROFILE_ID}
--cdp-port <port> 可选 Electron remote debugging port传入后通过 CDP renderer 执行 GUI 动作
@@ -251,6 +252,7 @@ function parseArgs(argv) {
}
const allowedScenarios = [
"complete",
UNKNOWN_ITEM_SCENARIO,
HOME_HOTPATH_SCENARIO,
HOME_HOTPATH_GREETING_SCENARIO,
"cancel",
@@ -0,0 +1,212 @@
import {
ASSISTANT_DONE_TEXT,
NEWS_PROMPT,
} from "./claw-chat-current-fixture-constants.mjs";
import { collectReadModelItems } from "./claw-chat-current-fixture-read-model-core.mjs";
import {
readArray,
readRecord,
readString,
} from "./claw-chat-current-fixture-utils.mjs";
export const UNKNOWN_ITEM_SCENARIO = "unknown-item";
export const UNKNOWN_ITEM_PROMPT = NEWS_PROMPT;
export const UNKNOWN_ITEM_DONE_TEXT = ASSISTANT_DONE_TEXT;
export const UNKNOWN_ITEM_UPSTREAM_TYPE = "futureCapability";
export const UNKNOWN_ITEM_SECRET_MARKER = "UNKNOWN_ITEM_SECRET_MUST_NOT_LEAK";
export const UNKNOWN_ITEM_SAFE_FIELD_NAMES = [
"[redacted]",
"label",
"opaquePayload",
"status",
];
export function renderUnknownItemBackendEventsExpression() {
return `
...(process.env.CLAW_CHAT_FIXTURE_SCENARIO === "${UNKNOWN_ITEM_SCENARIO}"
? [
{
type: "item.started",
payload: {
item: {
id: "unknown-item-" + currentTurnIdForItem,
threadId: currentThreadId(),
turnId: currentTurnId(),
type: "${UNKNOWN_ITEM_UPSTREAM_TYPE}",
label: "future capability",
opaquePayload: "opaque-value-must-not-render",
secretToken: "${UNKNOWN_ITEM_SECRET_MARKER}",
status: "inProgress"
}
}
},
{
type: "item.completed",
payload: {
item: {
id: "unknown-item-" + currentTurnIdForItem,
threadId: currentThreadId(),
turnId: currentTurnId(),
type: "${UNKNOWN_ITEM_UPSTREAM_TYPE}",
label: "future capability",
opaquePayload: "opaque-value-must-not-render",
secretToken: "${UNKNOWN_ITEM_SECRET_MARKER}",
status: "completed"
}
}
}
]
: []),`;
}
export function readUnknownItemRecoveryEvidence(value) {
const projectedItem = collectReadModelItems(value).find(
(item) =>
item?.type === "unknown_item" &&
item?.upstream_type === UNKNOWN_ITEM_UPSTREAM_TYPE,
);
if (projectedItem) {
return projectedItem;
}
const thread = readRecord(value?.thread);
const threadId = readString(thread, "id", "threadId", "thread_id");
for (const candidateTurn of readArray(thread, "turns")) {
const turn = readRecord(candidateTurn);
const item = readArray(turn, "items")
.map((candidate) => readRecord(candidate))
.find(
(candidate) =>
candidate?.type === "unknownItem" &&
candidate?.upstreamType === UNKNOWN_ITEM_UPSTREAM_TYPE,
);
if (!item) continue;
return {
id: readString(item, "id"),
thread_id: threadId,
turn_id: readString(turn, "id", "turnId", "turn_id"),
status: readString(turn, "status"),
upstream_type: readString(item, "upstreamType"),
field_names: readArray(item, "fieldNames").filter(
(fieldName) => typeof fieldName === "string",
),
};
}
return null;
}
export async function collectUnknownItemScenarioEvidence({ page, readModel }) {
const unsupportedItem = page.locator(
'[data-testid="timeline-unsupported-item"]',
{ hasText: UNKNOWN_ITEM_UPSTREAM_TYPE },
);
await unsupportedItem.waitFor({ state: "visible" });
const gui = await page.evaluate(
({ upstreamType, secretMarker, safeFieldNames }) => {
const rows = Array.from(
document.querySelectorAll('[data-testid="timeline-unsupported-item"]'),
);
const target = rows.find((row) =>
(row.textContent || "").includes(upstreamType),
);
const text = target?.textContent || "";
const bodyText = document.body?.textContent || "";
return {
count: rows.length,
visible: Boolean(target),
upstreamTypeVisible: text.includes(upstreamType),
safeFieldNamesVisible: safeFieldNames.every((name) =>
text.includes(name),
),
internalTypeHidden: !text.includes("unknown_item"),
rawValuesHidden:
!bodyText.includes(secretMarker) &&
!bodyText.includes("opaque-value-must-not-render") &&
!bodyText.includes("future capability"),
};
},
{
upstreamType: UNKNOWN_ITEM_UPSTREAM_TYPE,
secretMarker: UNKNOWN_ITEM_SECRET_MARKER,
safeFieldNames: UNKNOWN_ITEM_SAFE_FIELD_NAMES,
},
);
const item = readUnknownItemRecoveryEvidence(readModel);
const serializedReadModel = JSON.stringify(readModel || {});
return {
gui,
readModel: {
present: Boolean(item),
itemId: item?.id ?? null,
threadId: item?.thread_id ?? null,
turnId: item?.turn_id ?? null,
status: item?.status ?? null,
upstreamType: item?.upstream_type ?? null,
fieldNames: Array.isArray(item?.field_names) ? item.field_names : [],
rawValuesHidden:
!serializedReadModel.includes(UNKNOWN_ITEM_SECRET_MARKER) &&
!serializedReadModel.includes("opaque-value-must-not-render") &&
!serializedReadModel.includes("future capability"),
},
};
}
function backendEventTypesForPrompt(backendLedger) {
const startIndex = backendLedger.findIndex(
(entry) =>
entry?.kind === "turnStart" && entry?.inputText === UNKNOWN_ITEM_PROMPT,
);
if (startIndex < 0) return [];
const eventTypes = [];
for (const entry of backendLedger.slice(startIndex + 1)) {
if (entry?.kind === "turnStart") break;
if (entry?.kind === "backendEmit" && Array.isArray(entry.eventTypes)) {
eventTypes.push(...entry.eventTypes.filter(Boolean));
}
}
return eventTypes;
}
function includesOrdered(eventTypes, expected) {
let cursor = 0;
for (const eventType of eventTypes) {
if (eventType === expected[cursor]) cursor += 1;
if (cursor === expected.length) return true;
}
return false;
}
export function buildUnknownItemScenarioAssertions({
appServerRequestMethods,
backendLedger,
summary,
}) {
const evidence = summary.unknownItem ?? {};
const eventTypes = backendEventTypesForPrompt(backendLedger);
return {
unknownItemBackendLifecycleObserved: includesOrdered(eventTypes, [
"item.started",
"item.completed",
"turn.completed",
]),
unknownItemUsesCurrentAppServerRead:
appServerRequestMethods.includes("turn/start") &&
appServerRequestMethods.includes("thread/read"),
unknownItemGuiFailVisible:
evidence.gui?.visible === true &&
evidence.gui?.upstreamTypeVisible === true,
unknownItemGuiFieldsSanitized:
evidence.gui?.safeFieldNamesVisible === true &&
evidence.gui?.internalTypeHidden === true &&
evidence.gui?.rawValuesHidden === true,
unknownItemReadModelRecovered:
evidence.readModel?.present === true &&
evidence.readModel?.upstreamType === UNKNOWN_ITEM_UPSTREAM_TYPE &&
evidence.readModel?.status === "completed",
unknownItemReadModelFieldsSanitized:
UNKNOWN_ITEM_SAFE_FIELD_NAMES.every((name) =>
evidence.readModel?.fieldNames?.includes(name),
) && evidence.readModel?.rawValuesHidden === true,
};
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
buildUnknownItemScenarioAssertions,
readUnknownItemRecoveryEvidence,
renderUnknownItemBackendEventsExpression,
UNKNOWN_ITEM_SAFE_FIELD_NAMES,
UNKNOWN_ITEM_SCENARIO,
UNKNOWN_ITEM_UPSTREAM_TYPE,
} from "./claw-chat-current-fixture-unknown-item.mjs";
function positiveInput() {
return {
appServerRequestMethods: ["turn/start", "thread/read"],
backendLedger: [
{ kind: "turnStart", inputText: "整理今天的国际新闻" },
{
kind: "backendEmit",
eventTypes: [
"item.started",
"item.completed",
"provider.request.started",
"turn.completed",
],
},
],
summary: {
unknownItem: {
gui: {
visible: true,
upstreamTypeVisible: true,
safeFieldNamesVisible: true,
internalTypeHidden: true,
rawValuesHidden: true,
},
readModel: {
present: true,
upstreamType: UNKNOWN_ITEM_UPSTREAM_TYPE,
status: "completed",
fieldNames: UNKNOWN_ITEM_SAFE_FIELD_NAMES,
rawValuesHidden: true,
},
},
},
};
}
describe("unknown Item current fixture", () => {
it("renders one future Item lifecycle without adding a production fallback", () => {
const script = renderUnknownItemBackendEventsExpression();
expect(UNKNOWN_ITEM_SCENARIO).toBe("unknown-item");
expect(script).toContain('type: "item.started"');
expect(script).toContain('type: "item.completed"');
expect(script).toContain(`type: "${UNKNOWN_ITEM_UPSTREAM_TYPE}"`);
expect(script).toContain(
'secretToken: "UNKNOWN_ITEM_SECRET_MUST_NOT_LEAK"',
);
});
it("requires live, recovery and redaction evidence together", () => {
const assertions = buildUnknownItemScenarioAssertions(positiveInput());
expect(Object.values(assertions).every(Boolean)).toBe(true);
});
it("reads the typed v2 unknown item from its parent thread and turn", () => {
expect(
readUnknownItemRecoveryEvidence({
thread: {
id: "thread-1",
turns: [
{
id: "turn-1",
status: "completed",
items: [
{
id: "unknown-item-1",
type: "unknownItem",
upstreamType: UNKNOWN_ITEM_UPSTREAM_TYPE,
fieldNames: UNKNOWN_ITEM_SAFE_FIELD_NAMES,
},
],
},
],
},
}),
).toEqual({
id: "unknown-item-1",
thread_id: "thread-1",
turn_id: "turn-1",
status: "completed",
upstream_type: UNKNOWN_ITEM_UPSTREAM_TYPE,
field_names: UNKNOWN_ITEM_SAFE_FIELD_NAMES,
});
});
it("fails when the read model keeps raw values", () => {
const input = positiveInput();
input.summary.unknownItem.readModel.rawValuesHidden = false;
expect(
buildUnknownItemScenarioAssertions(input)
.unknownItemReadModelFieldsSanitized,
).toBe(false);
});
});
@@ -147,10 +147,25 @@ function main() {
"scripts/electron/session-history-fixture-smoke.test.mjs",
"scripts/electron/code-artifact-workbench-fixture-smoke.test.mjs",
"scripts/agent-runtime/claw-chat-current-fixture-smoke.test.mjs",
"scripts/agent-runtime/claw-chat-current-fixture-unknown-item.test.mjs",
]);
ensureElectronFixtureBuild(options);
runElectronFixtureSmoke(
"Claw unknown Item fail-visible/recovery Electron fixture",
[
"scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs",
"--scenario",
"unknown-item",
"--prefix",
"claw-chat-current-fixture-unknown-item-regression",
"--timeout-ms",
"240000",
],
options,
);
runElectronFixtureSmoke(
"Claw 首页首发热路径 Electron fixture",
[
@@ -158,6 +158,19 @@ describe("agent runtime current fixture regression smoke guard", () => {
expect(content).toContain("240000");
});
it("runs the unknown Item fail-visible and recovery Electron fixture", () => {
const content = readSmokeScript();
expect(content).toContain(
"Claw unknown Item fail-visible/recovery Electron fixture",
);
expect(content).toContain("--scenario");
expect(content).toContain("unknown-item");
expect(content).toContain(
"claw-chat-current-fixture-unknown-item-regression",
);
});
it("runs the real Electron approval decision fixtures in the current regression set", () => {
const content = readSmokeScript();
@@ -9,6 +9,7 @@ import { startOpenAiCompatibleFixtureServer } from "../lib/openai-compatible-fix
import { resolveElectronAppServerRuntimeEnv } from "../lib/electron-app-server-assets.mjs";
import { resolveDevAppServerBinary } from "../lib/electron-dev-sidecar.mjs";
import {
NORMAL_FINAL_TEXT,
REQUIRED_METHODS,
SOURCE_THREAD_ID,
WORKSPACE_ID,
@@ -16,6 +17,7 @@ import {
buildProviderScriptedResponses,
clearInvokeBuffers,
createPageAppServerClient,
createRepositoryProvider,
createTempRuntimeEnv,
initializeAndCommitImport,
providerRequestSummaries,
@@ -24,6 +26,8 @@ import {
sanitizeText,
summarizeAndAssertBridge,
summarizeAndAssertFixture,
TERMINAL_INTERACTION_CHARS,
TERMINAL_INTERACTION_SUMMARY,
waitForRendererReady,
writeJsonFile,
} from "./lib/codex-import-continuation-fixture.mjs";
@@ -44,6 +48,7 @@ const DEFAULTS = {
};
const LOG_PREFIX = "[smoke:codex-import-continuation-fixture]";
const NAVIGATION_RESTORE_STORAGE_KEY = "lime.appNavigation.restore.v1";
function printHelp() {
console.log(`
@@ -145,6 +150,293 @@ function evidencePaths(options) {
};
}
async function openSessionInRenderer(page, options, sessionId) {
await page.evaluate(
({ navigationKey, activeSessionId }) => {
window.sessionStorage.setItem(
navigationKey,
JSON.stringify({
page: "agent",
params: { initialSessionId: activeSessionId },
}),
);
},
{
navigationKey: NAVIGATION_RESTORE_STORAGE_KEY,
activeSessionId: sessionId,
},
);
await page.reload({
waitUntil: "domcontentloaded",
timeout: options.timeoutMs,
});
await waitForRendererReady(page, options);
const input = page.locator(
`textarea[name="agent-chat-message"][data-session-id="${sessionId}"]`,
);
await input.waitFor({ state: "visible", timeout: options.timeoutMs });
assert(
(await input.getAttribute("data-session-id")) === sessionId,
"Renderer 未恢复 unified exec canonical session",
);
}
async function waitForGuiTerminalInteraction(
page,
options,
{ sessionId, threadId, finalText, phase },
) {
const startedAt = Date.now();
const timeoutMs = Math.min(options.timeoutMs, 30_000);
let lastSnapshot = null;
while (Date.now() - startedAt < timeoutMs) {
const historicalPreview = page
.locator('[data-testid^="message-list-historical-timeline-preview:"]')
.first();
if (await historicalPreview.isVisible().catch(() => false)) {
await historicalPreview.click();
}
// Completed process groups are collapsed by the production timeline. Expand
// the command group before inspecting its real tool row.
await page.evaluate(() => {
for (const group of document.querySelectorAll(
'[data-testid="streaming-process-group"]',
)) {
const button = group.querySelector("button[aria-expanded]");
if (button?.getAttribute("aria-expanded") !== "true") {
button?.click();
}
}
});
lastSnapshot = await page.evaluate(
({ finalText, rawStdinText, threadId }) => {
const bodyText = document.body?.innerText || "";
const rows = Array.from(
document.querySelectorAll('[data-testid="tool-call-row"]'),
).map((row) => ({
text: row.textContent || "",
toolName: row.getAttribute("data-tool-name") || null,
status: row.getAttribute("data-tool-status") || null,
}));
const messageListFrame = document.querySelector(
'[data-testid="message-list-frame"]',
);
const turnStartTrace = (() => {
try {
const entries = JSON.parse(
window.localStorage.getItem("lime_invoke_trace_buffer_v1") || "[]",
);
if (!Array.isArray(entries)) {
return null;
}
for (const entry of entries) {
if (entry?.command !== "app_server_handle_json_lines") {
continue;
}
const lines = entry?.args_preview?.request?.lines;
if (!Array.isArray(lines)) {
continue;
}
for (const line of lines) {
try {
const message = JSON.parse(String(line));
if (
message?.method === "turn/start" &&
message?.params?.threadId === threadId
) {
return {
method: message.method,
threadId: message.params.threadId,
transport: entry.transport || null,
status: entry.status || null,
};
}
} catch {
// Ignore unrelated malformed diagnostic lines.
}
}
}
} catch {
return null;
}
return null;
})();
return {
url: window.location.href,
messageListSessionId:
messageListFrame?.getAttribute("data-session-id") || null,
commandRows: rows.filter((row) => row.toolName === "exec_command"),
hasSummary: bodyText.includes("sent 9 chars"),
hasRawStdin: bodyText.includes(rawStdinText),
hasFinalText: bodyText.includes(finalText),
turnStartTrace,
};
},
{
finalText,
rawStdinText: TERMINAL_INTERACTION_CHARS.trim(),
threadId,
},
);
if (
lastSnapshot?.messageListSessionId &&
lastSnapshot.messageListSessionId === sessionId &&
lastSnapshot.hasSummary &&
lastSnapshot.hasFinalText &&
!lastSnapshot.hasRawStdin &&
lastSnapshot.commandRows.some((row) => row.status === "completed")
) {
return {
phase,
sessionId,
threadId,
url: lastSnapshot.url,
messageListSessionId: lastSnapshot.messageListSessionId,
commandRowCount: lastSnapshot.commandRows.length,
terminalInteractionSummary: TERMINAL_INTERACTION_SUMMARY,
turnStartTrace: lastSnapshot.turnStartTrace,
rawStdinProjected: false,
finalTextVisible: true,
};
}
await new Promise((resolve) => setTimeout(resolve, options.intervalMs));
}
throw new Error(
`${phase} GUI terminal interaction 摘要未出现: ${JSON.stringify(
sanitizeJson(lastSnapshot),
)}`,
);
}
async function selectRuntimeRouteInRenderer(
page,
options,
{ sessionId, route },
) {
assert(
route?.providerName && route?.model,
"Renderer 模型选择缺少 provider/model route",
);
const input = page.locator(
`textarea[name="agent-chat-message"][data-session-id="${sessionId}"]`,
);
const modelSelector = input.locator(
'xpath=ancestor::*[@data-testid="inputbar-core-container"][1]//*[@data-testid="model-selector"]',
);
await modelSelector.waitFor({
state: "visible",
timeout: Math.min(options.timeoutMs, 10_000),
});
await modelSelector.click();
const popover = page.locator('[data-model-selector-popover="true"]');
await popover.waitFor({
state: "visible",
timeout: Math.min(options.timeoutMs, 10_000),
});
const providerButton = popover
.locator("button")
.filter({ hasText: route.providerName })
.first();
await providerButton.waitFor({
state: "visible",
timeout: Math.min(options.timeoutMs, 10_000),
});
await providerButton.click();
const modelButton = popover
.locator("button")
.filter({ hasText: route.model })
.first();
await modelButton.waitFor({
state: "visible",
timeout: Math.min(options.timeoutMs, 10_000),
});
await modelButton.click();
await popover.waitFor({
state: "hidden",
timeout: Math.min(options.timeoutMs, 10_000),
});
const selectionHandle = await page.waitForFunction(
({ expectedModel, expectedProvider, activeSessionId }) => {
const textarea = document.querySelector(
`textarea[name="agent-chat-message"][data-session-id="${activeSessionId}"]`,
);
const container = textarea?.closest(
'[data-testid="inputbar-core-container"]',
);
const trigger = container?.querySelector('[data-testid="model-selector"]');
const text = trigger?.textContent?.trim() || "";
const title = trigger?.getAttribute("title") || "";
return text.includes(expectedModel) && title.includes(expectedProvider)
? { text, title }
: null;
},
{
expectedModel: route.model,
expectedProvider: route.providerName,
activeSessionId: sessionId,
},
{ timeout: Math.min(options.timeoutMs, 10_000) },
);
const selection = await selectionHandle.jsonValue();
return {
sessionId,
providerId: route.providerId,
providerName: route.providerName,
model: route.model,
triggerText: selection.text,
triggerTitle: selection.title,
};
}
async function runNormalTurnInRenderer(
page,
options,
{ normalSessionId, normalThreadId, route, text },
) {
const input = page.locator(
`textarea[name="agent-chat-message"][data-session-id="${normalSessionId}"]`,
);
await input.fill(text);
const sendButton = input.locator(
'xpath=ancestor::*[@data-testid="inputbar-core-container"][1]//*[@data-testid="send-btn"]',
);
await page.waitForFunction(
({ activeSessionId, expectedModel, expectedText }) => {
const textarea = document.querySelector(
`textarea[name="agent-chat-message"][data-session-id="${activeSessionId}"]`,
);
const container = textarea?.closest(
'[data-testid="inputbar-core-container"]',
);
const trigger = container?.querySelector('[data-testid="model-selector"]');
const button = container?.querySelector('[data-testid="send-btn"]');
return Boolean(
textarea instanceof HTMLTextAreaElement &&
textarea.value === expectedText &&
trigger?.textContent?.includes(expectedModel) &&
button instanceof HTMLButtonElement &&
!button.disabled,
);
},
{
activeSessionId: normalSessionId,
expectedModel: route.model,
expectedText: text,
},
{ timeout: Math.min(options.timeoutMs, 10_000) },
);
await sendButton.click({ timeout: Math.min(options.timeoutMs, 10_000) });
return await waitForGuiTerminalInteraction(page, options, {
sessionId: normalSessionId,
threadId: normalThreadId,
finalText: NORMAL_FINAL_TEXT,
phase: "live",
});
}
async function run() {
const options = parseArgs(process.argv.slice(2));
fs.mkdirSync(options.evidenceDir, { recursive: true });
@@ -154,7 +446,10 @@ async function run() {
let providerFixture = null;
let app = null;
let page = null;
let client = null;
const consoleErrors = [];
const guiTerminalInteraction = { live: null, reloaded: null };
let guiModelSelection = null;
const appServerBinary = resolveDevAppServerBinary({
env: runtimeEnv.env,
@@ -177,6 +472,9 @@ async function run() {
providerBaseUrl: null,
gateBBridge: null,
fixtureSummary: null,
importedIdentity: null,
guiTerminalInteraction,
guiModelSelection,
consoleErrors,
screenshot: null,
rawEvidence: paths.raw,
@@ -225,14 +523,25 @@ async function run() {
summary.electronPreloadBridge =
renderer.electron && renderer.hasInvokeBridge;
await clearInvokeBuffers(page);
const client = createPageAppServerClient(page);
client = createPageAppServerClient(page);
logStage("commit-import-zero-replay");
const initial = await initializeAndCommitImport(
client,
runtimeEnv,
options,
{
...options,
beforeCommit: () =>
createRepositoryProvider(client, providerFixture.provider),
},
);
const route = initial.beforeCommitResult;
summary.importedIdentity = sanitizeJson({
jobSessionId: initial.sessionId,
jobThreadId: initial.threadId,
readSessionId: initial.importedRead?.thread?.sessionId ?? null,
readThreadId: initial.importedRead?.thread?.id ?? null,
});
const providerRequestsAfterCommit = providerFixture.requests.length;
assert(
providerRequestsAfterCommit === 0,
@@ -243,10 +552,52 @@ async function run() {
const turns = await runImportedAndNormalTurns(client, {
importedSessionId: initial.sessionId,
importedThreadId: initial.threadId,
provider: providerFixture.provider,
route,
runtimeEnv,
command: providerScript.command,
options,
onNormalThreadReady: async ({
normalSessionId,
normalThreadId,
route: normalRoute,
}) => {
logStage("open-normal-thread-before-live-turn");
summary.normalIdentity = {
sessionId: normalSessionId,
threadId: normalThreadId,
};
await openSessionInRenderer(page, options, normalSessionId);
assert(normalThreadId, "normal thread identity 不能为空");
logStage("select-normal-thread-fixture-model");
guiModelSelection = await selectRuntimeRouteInRenderer(page, options, {
sessionId: normalSessionId,
route: normalRoute,
});
summary.guiModelSelection = sanitizeJson(guiModelSelection);
},
runNormalTurnInRenderer: async (context) => {
logStage("send-normal-turn-through-renderer");
guiTerminalInteraction.live = await runNormalTurnInRenderer(
page,
options,
context,
);
return guiTerminalInteraction.live;
},
onNormalTurnCompleted: async ({
normalSessionId,
normalThreadId,
}) => {
logStage("reload-and-assert-terminal-interaction-recovery");
await openSessionInRenderer(page, options, normalSessionId);
guiTerminalInteraction.reloaded =
await waitForGuiTerminalInteraction(page, options, {
sessionId: normalSessionId,
threadId: normalThreadId,
finalText: NORMAL_FINAL_TEXT,
phase: "reloaded",
});
},
});
writeJsonFile(
paths.raw,
@@ -256,7 +607,10 @@ async function run() {
paths.provider,
sanitizeJson(providerRequestSummaries(providerFixture.requests)),
);
summary.gateBBridge = sanitizeJson(summarizeAndAssertBridge(client));
summary.gateBBridge = sanitizeJson({
...summarizeAndAssertBridge(client),
rendererTurnStart: guiTerminalInteraction.live?.turnStartTrace ?? null,
});
const fixtureSummary = summarizeAndAssertFixture({
client,
initial,
@@ -284,6 +638,64 @@ async function run() {
);
} catch (error) {
summary.error = error instanceof Error ? error.message : String(error);
if (page) {
try {
summary.failureDiagnostics = await page.evaluate(() => {
const readJson = (key) => {
try {
const parsed = JSON.parse(localStorage.getItem(key) || "[]");
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const trace = readJson("lime_invoke_trace_buffer_v1");
return {
bodyText: document.body?.innerText || "",
processGroups: Array.from(
document.querySelectorAll('[data-testid="streaming-process-group"]'),
).map((group) => ({
text: group.textContent || "",
expanded:
group.querySelector("button")?.getAttribute("aria-expanded") ||
null,
})),
toolRows: Array.from(
document.querySelectorAll('[data-testid="tool-call-row"]'),
).map((row) => ({
text: row.textContent || "",
toolName: row.getAttribute("data-tool-name") || null,
status: row.getAttribute("data-tool-status") || null,
})),
trace: trace.slice(-40),
invokeErrors: readJson("lime_invoke_error_buffer_v1").slice(-20),
};
});
} catch {
summary.failureDiagnostics = { unavailable: true };
}
}
summary.failureClient = sanitizeJson({
requests: client?.requests ?? [],
messages: client?.messages ?? [],
});
if (client && summary.normalIdentity?.threadId) {
try {
summary.failureThreadRead = sanitizeJson(
await client.call("thread/read", {
threadId: summary.normalIdentity.threadId,
includeTurns: true,
}),
);
} catch {
summary.failureThreadRead = null;
}
}
if (providerFixture) {
summary.failureProviderRequests = sanitizeJson(
providerRequestSummaries(providerFixture.requests),
);
}
if (page) {
try {
await page.screenshot({
@@ -1,5 +1,9 @@
import fs from "node:fs";
import { describe, expect, it } from "vitest";
import {
buildProviderScriptedResponses,
TERMINAL_INTERACTION_CHARS,
} from "./lib/codex-import-continuation-fixture.mjs";
function readSmokeSources() {
return [
@@ -30,7 +34,8 @@ describe("codex import continuation Electron fixture smoke guard", () => {
expect(content).toContain("app_server_handle_json_lines");
expect(content).toContain("window.__LIME_ELECTRON__ === true");
expect(content).toContain("client.bridgeFacts.every");
expect(content).toContain("turnStartCount === 2");
expect(content).toContain("turnStartCount === 1");
expect(content).toContain("rendererTurnStart");
});
it("uses the runtime provider loop for imported and normal unified exec turns", () => {
@@ -39,6 +44,12 @@ describe("codex import continuation Electron fixture smoke guard", () => {
expect(content).toContain("startOpenAiCompatibleFixtureServer");
expect(content).toContain('APP_SERVER_BACKEND_MODE: "runtime"');
expect(content).toContain('name: "exec_command"');
expect(content).toContain('name: "write_stdin"');
expect(content).toContain("session_id");
expect(content).toContain("TERMINAL_INTERACTION_SUMMARY");
expect(content).toContain(
'message?.method === "item/commandExecution/terminalInteraction"',
);
expect(content).toContain('request.toolNames.includes("exec_command")');
expect(content).toContain('request.toolNames.includes("write_stdin")');
expect(content).toContain("providerRequestsAfterCommit === 0");
@@ -50,6 +61,51 @@ describe("codex import continuation Electron fixture smoke guard", () => {
expect(content).not.toContain("APP_SERVER_BACKEND_COMMAND");
});
it("derives write_stdin session identity from the preceding real tool result", async () => {
const scripted = buildProviderScriptedResponses({
workspaceRoot: "/fixture-workspace",
});
expect(scripted.responses).toHaveLength(6);
const importedWrite = await scripted.responses[1]({
body: {
messages: [
{
role: "tool",
content: '{"output":"","session_id":1234}',
},
],
},
});
const normalWrite = await scripted.responses[4]({
body: {
messages: [
{
role: "tool",
content: '{"session_id":5678,"output":""}',
},
],
},
});
expect(importedWrite).toMatchObject({
type: "tool_call",
name: "write_stdin",
arguments: {
session_id: 1234,
chars: TERMINAL_INTERACTION_CHARS,
},
});
expect(normalWrite).toMatchObject({
type: "tool_call",
name: "write_stdin",
arguments: {
session_id: 5678,
chars: TERMINAL_INTERACTION_CHARS,
},
});
});
it("imports canonical history details without replaying historical tools", () => {
const content = readSmokeSources();
@@ -57,6 +113,7 @@ describe("codex import continuation Electron fixture smoke guard", () => {
expect(content).toContain('"modelProvider/create"');
expect(content).toContain('"modelProvider/update"');
expect(content).toContain('"modelProviderKey/create"');
expect(content).toContain('"model/list"');
expect(content).toContain('"thread/read"');
expect(content).toContain('"thread/start"');
expect(content).toContain('"thread/settings/update"');
@@ -74,6 +131,11 @@ describe("codex import continuation Electron fixture smoke guard", () => {
expect(content).toContain("historical.hasWebSearchItem");
expect(content).toContain("historical.hasApprovalFidelity");
expect(content).toContain("job.result?.session?.threadId");
expect(content).toContain("beforeCommitResult");
expect(content).toContain("model: FIXTURE_MODEL");
expect(content).toContain("createRepositoryProvider");
expect(content).toContain("provider.providerConfig.modelCapabilities");
expect(content).toContain("catalogModel.capabilitySnapshot");
expect(content).toContain("threadId,");
expect(content).toContain("includeTurns: true");
expect(content).toContain('input: [{ type: "text", text }]');
@@ -83,6 +145,28 @@ describe("codex import continuation Electron fixture smoke guard", () => {
expect(content).not.toContain("runtimeOptions(");
});
it("requires the visible redacted summary before and after renderer reload", () => {
const content = readSmokeSources();
expect(content).toContain("open-normal-thread-before-live-turn");
expect(content).toContain("select-normal-thread-fixture-model");
expect(content).toContain("send-normal-turn-through-renderer");
expect(content).toContain(
"reload-and-assert-terminal-interaction-recovery",
);
expect(content).toContain("lime.appNavigation.restore.v1");
expect(content).toContain("openSessionInRenderer");
expect(content).toContain('data-testid="send-btn"');
expect(content).toContain('data-testid="model-selector"');
expect(content).toContain('data-model-selector-popover="true"');
expect(content).toContain("selectRuntimeRouteInRenderer");
expect(content).toContain("providerName: route.providerName");
expect(content).toContain("trigger?.textContent?.includes(expectedModel)");
expect(content).toContain("sendButton.click");
expect(content).toContain("waitForGuiTerminalInteraction");
expect(content).toContain("rawStdinProjected: false");
});
it("keeps retired shell names negative-only and excludes production mock fallback", () => {
const content = readSmokeSources();
@@ -5,10 +5,12 @@ import process from "node:process";
export const APP_SERVER_HANDLE_JSON_LINES_COMMAND =
"app_server_handle_json_lines";
export const APP_SERVER_DRAIN_EVENTS_COMMAND = "app_server_drain_events";
export const WORKSPACE_ID = "codex-import-continuation-workspace";
export const SOURCE_THREAD_ID = "codex-import-continuation-thread";
export const IMPORTED_TURN_ID = "codex-import-live-exec-turn";
export const NORMAL_TURN_ID = "codex-normal-live-exec-turn";
export const NORMAL_THREAD_TITLE = "Codex unified exec normal fixture";
export const IMPORTED_USER_TEXT = "请运行测试并修复失败";
export const IMPORTED_REASONING_TEXT =
"I need to inspect the test failure first.";
@@ -18,13 +20,18 @@ export const NORMAL_USER_TEXT = "在普通会话中执行统一命令并汇报
export const IMPORTED_FINAL_TEXT = "CODEX_IMPORTED_UNIFIED_EXEC_DONE";
export const NORMAL_FINAL_TEXT = "CODEX_NORMAL_UNIFIED_EXEC_DONE";
export const COMMAND_OUTPUT_MARKER = "CODEX_UNIFIED_EXEC_OK";
export const FIXTURE_MODEL = "lime-fixture-chat";
export const TERMINAL_INTERACTION_CHARS = "continue\n";
export const TERMINAL_INTERACTION_SUMMARY = "sent 9 chars";
export const REQUIRED_METHODS = [
"initialize",
"conversationImport/thread/commit",
"modelProvider/create",
"modelProvider/update",
"modelProviderKey/create",
"model/list",
"thread/read",
"thread/resume",
"thread/start",
"thread/settings/update",
"turn/start",
@@ -133,13 +140,34 @@ export function createTempRuntimeEnv() {
export function buildProviderScriptedResponses(runtimeEnv) {
const escapedNodePath = process.execPath.replaceAll('"', '\\"');
const command = `"${escapedNodePath}" -e "process.stdout.write('${COMMAND_OUTPUT_MARKER}')"`;
const command = `"${escapedNodePath}" -e "process.stdin.setEncoding('utf8');process.stdin.once('data',input=>{process.stdout.write('${COMMAND_OUTPUT_MARKER}');process.exit(0);})"`;
const toolArguments = {
cmd: command,
workdir: runtimeEnv.workspaceRoot,
yield_time_ms: 10_000,
max_output_tokens: 2_000,
};
const writeStdinResponse = ({ body }) => {
const bodyText = JSON.stringify(body ?? {});
const matches = [
...bodyText.matchAll(/(?:\\?"session_id\\?"|session_id)\s*:\s*(\d+)/g),
];
const sessionId = Number(matches.at(-1)?.[1]);
if (!Number.isInteger(sessionId)) {
throw new Error("write_stdin fixture 未能从上一轮 tool result 提取 session_id");
}
return {
type: "tool_call",
id: "call-write-stdin",
name: "write_stdin",
arguments: {
session_id: sessionId,
chars: TERMINAL_INTERACTION_CHARS,
yield_time_ms: 10_000,
max_output_tokens: 2_000,
},
};
};
return {
command,
responses: [
@@ -149,6 +177,7 @@ export function buildProviderScriptedResponses(runtimeEnv) {
name: "exec_command",
arguments: toolArguments,
},
writeStdinResponse,
{ type: "text", content: IMPORTED_FINAL_TEXT },
{
type: "tool_call",
@@ -156,6 +185,10 @@ export function buildProviderScriptedResponses(runtimeEnv) {
name: "exec_command",
arguments: toolArguments,
},
({ body }) => ({
...writeStdinResponse({ body }),
id: "call-normal-write-stdin",
}),
{ type: "text", content: NORMAL_FINAL_TEXT },
],
};
@@ -171,6 +204,7 @@ function writeCodexRolloutFixture(rolloutPath, workspaceRoot) {
timestamp: "2026-06-16T00:00:00.000Z",
cwd: workspaceRoot,
source: "cli",
model: FIXTURE_MODEL,
model_provider: "openai",
},
},
@@ -367,10 +401,50 @@ export function createPageAppServerClient(page) {
return decoded;
}
async function drainEvents(limit = 100) {
const envelope = await page.evaluate(
async ({ command, limit: drainLimit }) => {
const invoke = window.electronAPI?.invoke;
if (typeof invoke !== "function") {
throw new Error("Electron preload invoke bridge is unavailable");
}
return {
bridge: {
electron: window.__LIME_ELECTRON__ === true,
hasInvoke: true,
supportsCommand:
typeof window.electronAPI?.supportsCommand === "function" &&
window.electronAPI.supportsCommand(command),
},
response: await invoke(command, {
request: { includeRecent: true, limit: drainLimit },
}),
};
},
{ command: APP_SERVER_DRAIN_EVENTS_COMMAND, limit },
);
bridgeFacts.push(envelope?.bridge ?? null);
const response = envelope?.response;
const decoded = Array.isArray(response?.lines)
? response.lines
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean)
: [];
messages.push(...decoded);
return decoded;
}
return {
requests,
messages,
bridgeFacts,
drainEvents,
async call(method, params = {}) {
const id = `codex-import-unified-exec-${++requestIndex}`;
requests.push({ id, method });
@@ -443,6 +517,7 @@ export async function initializeAndCommitImport(
capabilities: { eventMethods: ["agentSession/event"] },
});
await client.notify("initialized");
const beforeCommitResult = await options.beforeCommit?.();
const commit = await client.call("conversationImport/thread/commit", {
sourceClient: "codex",
sourceRoot: runtimeEnv.sourceRoot,
@@ -460,7 +535,15 @@ export async function initializeAndCommitImport(
threadId,
includeTurns: true,
});
return { initialize, commit, job, importedRead, sessionId, threadId };
return {
initialize,
beforeCommitResult,
commit,
job,
importedRead,
sessionId,
threadId,
};
}
function canonicalTurns(read) {
@@ -483,6 +566,16 @@ function contentTextFromUserItem(item) {
.trim();
}
function findTurnByUserText(read, expectedText) {
return canonicalTurns(read).find((turn) =>
(Array.isArray(turn?.items) ? turn.items : []).some(
(item) =>
item?.type === "userMessage" &&
contentTextFromUserItem(item) === expectedText,
),
);
}
function findCompletedCommand(read, turnId, expectedCommand) {
return canonicalItems(read).find(
({ item, turnId: itemTurnId }) =>
@@ -495,6 +588,20 @@ function findCompletedCommand(read, turnId, expectedCommand) {
)?.item;
}
function readTerminalInteractions(item) {
const interactions = item?.terminalInteractions ?? item?.terminal_interactions;
return Array.isArray(interactions) ? interactions : [];
}
function findTerminalInteractionNotification(messages, turnId) {
return messages.find(
(message) =>
message?.method === "item/commandExecution/terminalInteraction" &&
message?.params?.turnId === turnId &&
message?.params?.stdin === TERMINAL_INTERACTION_SUMMARY,
);
}
async function waitForTurnCompletion(
client,
{ threadId, turnId, expectedCommand, finalText, timeoutMs, intervalMs },
@@ -502,6 +609,7 @@ async function waitForTurnCompletion(
const startedAt = Date.now();
let latestRead = null;
while (Date.now() - startedAt < timeoutMs) {
await client.drainEvents();
latestRead = await client.call("thread/read", {
threadId,
includeTurns: true,
@@ -529,9 +637,31 @@ async function waitForTurnCompletion(
);
}
async function createRepositoryProvider(client, provider) {
async function waitForTerminalInteractionNotification(
client,
{ turnId, timeoutMs, intervalMs },
) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
await client.drainEvents();
const notification = findTerminalInteractionNotification(
client.messages,
turnId,
);
if (notification) {
return notification;
}
await sleep(intervalMs);
}
throw new Error(
`等待 terminalInteraction notification 超时: turn=${turnId}`,
);
}
export async function createRepositoryProvider(client, provider) {
const providerName = `Codex import continuation fixture ${Date.now()}`;
const created = await client.call("modelProvider/create", {
name: `Codex import continuation fixture ${Date.now()}`,
name: providerName,
providerType: provider.providerName,
apiHost: provider.providerConfig.baseUrl,
});
@@ -541,7 +671,12 @@ async function createRepositoryProvider(client, provider) {
providerId,
enabled: true,
sortOrder: 1,
models: [{ id: provider.modelPreference }],
models: [
{
id: provider.modelPreference,
capability: provider.providerConfig.modelCapabilities,
},
],
});
const key = await client.call("modelProviderKey/create", {
providerId,
@@ -550,7 +685,31 @@ async function createRepositoryProvider(client, provider) {
replaceExisting: true,
});
assert(key?.key?.id, "modelProviderKey/create did not return key.id");
return { providerId, model: provider.modelPreference };
const catalog = await client.call("model/list", {
includeHidden: true,
limit: 500,
});
const catalogModel = Array.isArray(catalog?.data)
? catalog.data.find(
(candidate) =>
candidate?.providerId === providerId &&
candidate?.model === provider.modelPreference,
)
: null;
assert(catalogModel, "model/list 未返回可执行 fixture route");
const capability = catalogModel.capabilitySnapshot;
assert(
capability?.taskFamilies?.includes("chat") &&
capability?.outputModalities?.includes("text") &&
capability?.runtimeFeatures?.includes("tool_calling"),
"fixture route 缺少 chat/text/tool_calling capability",
);
return {
catalogModel,
providerId,
providerName,
model: provider.modelPreference,
};
}
async function updateThreadProvider(client, threadId, route) {
@@ -594,13 +753,27 @@ export async function runImportedAndNormalTurns(
{
importedSessionId,
importedThreadId,
provider,
route,
runtimeEnv,
command,
options,
onNormalThreadReady,
runNormalTurnInRenderer,
onNormalTurnCompleted,
},
) {
const route = await createRepositoryProvider(client, provider);
assert(
route?.providerId && route?.model,
"provider route 必须在导入 commit 前完成准备",
);
const importedResume = await client.call("thread/resume", {
threadId: importedThreadId,
excludeTurns: true,
});
assert(
importedResume?.thread?.id === importedThreadId,
"导入续聊 thread/resume 未返回同一 canonical thread",
);
await updateThreadProvider(client, importedThreadId, route);
const importedTurn = await startUnifiedExecTurn(client, {
threadId: importedThreadId,
@@ -622,6 +795,14 @@ export async function runImportedAndNormalTurns(
timeoutMs: options.timeoutMs,
intervalMs: options.intervalMs,
});
const importedTerminalInteraction = await waitForTerminalInteractionNotification(
client,
{
turnId: importedTurnId,
timeoutMs: options.timeoutMs,
intervalMs: options.intervalMs,
},
);
const normalStart = await client.call("thread/start", {
model: route.model,
@@ -629,7 +810,7 @@ export async function runImportedAndNormalTurns(
cwd: runtimeEnv.workspaceRoot,
runtimeWorkspaceRoots: [runtimeEnv.workspaceRoot],
historyMode: "paginated",
serviceName: "Codex unified exec normal fixture",
serviceName: NORMAL_THREAD_TITLE,
threadSource: "appServer",
});
const normalSessionId = String(normalStart?.thread?.sessionId || "").trim();
@@ -638,33 +819,81 @@ export async function runImportedAndNormalTurns(
normalSessionId && normalThreadId,
"thread/start did not return canonical session/thread identity",
);
const normalTurn = await startUnifiedExecTurn(client, {
threadId: normalThreadId,
clientUserMessageId: NORMAL_TURN_ID,
text: NORMAL_USER_TEXT,
route,
runtimeEnv,
});
const normalTurnId = String(normalTurn?.turn?.id || "").trim();
assert(normalTurnId, "normal turn/start did not return canonical turn.id");
const normalRead = await waitForTurnCompletion(client, {
threadId: normalThreadId,
turnId: normalTurnId,
expectedCommand: command,
finalText: NORMAL_FINAL_TEXT,
timeoutMs: options.timeoutMs,
intervalMs: options.intervalMs,
await updateThreadProvider(client, normalThreadId, route);
await onNormalThreadReady?.({ normalSessionId, normalThreadId, route });
let normalTurn;
let normalTurnId;
let normalRead;
let normalRendererResult = null;
if (runNormalTurnInRenderer) {
normalRendererResult = await runNormalTurnInRenderer({
normalSessionId,
normalThreadId,
route,
text: NORMAL_USER_TEXT,
});
normalRead = await client.call("thread/read", {
threadId: normalThreadId,
includeTurns: true,
});
const projectedTurn = findTurnByUserText(normalRead, NORMAL_USER_TEXT);
normalTurnId = String(projectedTurn?.id || "").trim();
assert(
projectedTurn?.status === "completed" && normalTurnId,
"Renderer normal turn 未进入 canonical completed 状态",
);
assert(
findCompletedCommand(normalRead, normalTurnId, command),
"Renderer normal turn 未产生 completed Command Item",
);
assert(
(Array.isArray(projectedTurn?.items) ? projectedTurn.items : []).some(
(item) =>
item?.type === "agentMessage" &&
String(item?.text || "").includes(NORMAL_FINAL_TEXT),
),
"Renderer normal turn canonical read 缺少最终文本",
);
normalTurn = { turn: projectedTurn };
} else {
normalTurn = await startUnifiedExecTurn(client, {
threadId: normalThreadId,
clientUserMessageId: NORMAL_TURN_ID,
text: NORMAL_USER_TEXT,
route,
runtimeEnv,
});
normalTurnId = String(normalTurn?.turn?.id || "").trim();
assert(normalTurnId, "normal turn/start did not return canonical turn.id");
normalRead = await waitForTurnCompletion(client, {
threadId: normalThreadId,
turnId: normalTurnId,
expectedCommand: command,
finalText: NORMAL_FINAL_TEXT,
timeoutMs: options.timeoutMs,
intervalMs: options.intervalMs,
});
}
await onNormalTurnCompleted?.({
normalSessionId,
normalThreadId,
normalTurnId,
normalRead,
normalRendererResult,
});
return {
importedTurn,
importedResume,
importedTurnId,
importedRead,
importedTerminalInteraction,
normalSessionId,
normalThreadId,
normalStart,
normalTurn,
normalTurnId,
normalRead,
normalRendererResult,
route,
};
}
@@ -703,6 +932,9 @@ function commandShape(item) {
cwd: item?.cwd ?? null,
aggregatedOutput: item?.aggregatedOutput ?? null,
exitCode: item?.exitCode ?? null,
terminalInteractionSummaries: readTerminalInteractions(item).map(
(interaction) => interaction?.stdin ?? null,
),
};
}
@@ -806,13 +1038,36 @@ export function summarizeAndAssertFixture({
assert(historical.hasApprovalFidelity, "导入 approval fidelity 计数丢失");
assert(importedCommand, "导入续聊未产生 completed Command Item");
assert(normalCommand, "普通会话未产生 completed Command Item");
const importedInteractions = readTerminalInteractions(importedCommand);
const normalInteractions = readTerminalInteractions(normalCommand);
assert(
importedInteractions.some(
(interaction) => interaction?.stdin === TERMINAL_INTERACTION_SUMMARY,
),
"导入续聊 canonical Command Item 未保留脱敏 terminal interaction",
);
assert(
normalInteractions.some(
(interaction) => interaction?.stdin === TERMINAL_INTERACTION_SUMMARY,
),
"普通会话 canonical Command Item 未保留脱敏 terminal interaction",
);
assert(
findTerminalInteractionNotification(client.messages, turns.importedTurnId),
"未观察到导入续聊 terminalInteraction live notification",
);
assert(
turns.normalRendererResult?.terminalInteractionSummary ===
TERMINAL_INTERACTION_SUMMARY,
"普通会话 Renderer 未消费 terminalInteraction live notification",
);
assert(
JSON.stringify(importedShape) === JSON.stringify(normalShape),
`导入续聊与普通会话 Command Item 不同构: ${JSON.stringify({ importedShape, normalShape })}`,
);
assert(
providerSummaries.length === 4,
"两个 unified exec turn 应产生 4 次 provider 请求",
providerSummaries.length === 6,
"两个 unified exec + write_stdin turn 应产生 6 次 provider 请求",
);
for (const request of providerSummaries) {
assert(
@@ -837,6 +1092,12 @@ export function summarizeAndAssertFixture({
historical,
importedCommandShape: importedShape,
normalCommandShape: normalShape,
importedTerminalInteractions: importedInteractions,
normalTerminalInteractions: normalInteractions,
liveTerminalInteractionTurnIds: [
turns.importedTurnId,
turns.normalTurnId,
],
commandShapesIsomorphic:
JSON.stringify(importedShape) === JSON.stringify(normalShape),
};
@@ -853,15 +1114,16 @@ export function summarizeAndAssertBridge(client) {
),
"App Server 调用未全部经过真实 Electron preload bridge",
);
assert(
turnStartCount === 2,
"未通过 current method 发起导入/普通两次 turn start",
);
assert(turnStartCount === 1, "导入 turn 未通过 current method 发起");
return {
electron: true,
preloadInvoke: true,
command: APP_SERVER_HANDLE_JSON_LINES_COMMAND,
callCount: client.bridgeFacts.length,
turnStartCount,
directTurnStartCount: turnStartCount,
terminalInteractionNotificationCount: client.messages.filter(
(message) =>
message?.method === "item/commandExecution/terminalInteraction",
).length,
};
}
@@ -0,0 +1,497 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { resolveElectronAppServerRuntimeEnv } from "../lib/electron-app-server-assets.mjs";
import { resolveDevAppServerBinary } from "../lib/electron-dev-sidecar.mjs";
import { writeMcpFixture } from "../mcp/lib/current-smoke-fixture.mjs";
import {
APP_SERVER_HANDLE_JSON_LINES_COMMAND,
LEGACY_MCP_COMMANDS,
writeJsonFile,
} from "../mcp/lib/current-smoke-transport.mjs";
import {
appServerCallFromPage,
assert,
closeElectronFixture,
createTempRuntimeEnv,
launchElectronFixture,
openSettings,
parseInvokeTraceRaw,
parseJsonRpcRequestsFromInvokeTrace,
sanitizeText,
} from "./mcp-config-fixture-smoke.mjs";
import { parseMcpConfigFixtureArgs } from "./lib/mcp-config-fixture-evidence.mjs";
const APP_SERVER_DRAIN_EVENTS_COMMAND = "app_server_drain_events";
const STARTUP_NOTIFICATION_METHOD = "mcpServer/startupStatus/updated";
const REQUIRED_REFRESH_METHODS = ["mcpServerStatus/list", "mcpTool/list"];
const STARTING_COPY = /启动中|啟動中|Starting|起動中|시작 중/i;
const RUNNING_COPY = /运行中|執行中|Running|実行中|실행 중/i;
const LATEST_ERROR_COPY =
/最近错误|最近錯誤|Latest error|最新エラー|최근 오류/i;
const DEFAULTS = {
runId: process.env.LIME_GATE_RUN_ID?.trim() || null,
evidenceDir: path.join(
process.cwd(),
".lime",
"qc",
"mcp-startup-notification",
),
prefix: "mcp-startup-notification-fixture",
timeoutMs: 120_000,
intervalMs: 250,
keepTemp: false,
};
const LOG_PREFIX = "[smoke:mcp-startup-notification-fixture]";
function printHelp() {
console.log(`
MCP Startup Notification Electron Fixture Smoke
用途:
在真实 Electron Settings MCP 页面验证 App Server typed notification
mcpServer/startupStatus/updated starting -> ready / failed 投影
边界:
使用临时 stdio MCP server 和临时失败配置不调用正式模型或 live provider
不允许 App Server mock backendrenderer fallback 或旧 MCP Desktop event
用法:
npm run smoke:mcp-startup-notification-electron-fixture
选项:
--run-id <id> --evidence-dir <path> --prefix <name> --timeout-ms <ms>
--interval-ms <ms> --keep-temp -h|--help
`);
}
function logStage(stage) {
console.log(`${LOG_PREFIX} stage=${stage}`);
}
function serverCard(page, serverName) {
return page
.getByText(serverName, { exact: true })
.first()
.locator(
'xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " border ")][1]',
);
}
async function openMcpRuntimeSettings(page, options) {
await openSettings(page, options);
await page.locator('[data-testid="settings-sidebar-tab-mcp-server"]').click();
const runtimeTab = page.locator('[data-testid="mcp-panel-tab-runtime"]');
await runtimeTab.waitFor({
state: "visible",
timeout: Math.min(45_000, options.timeoutMs),
});
await runtimeTab.click();
}
async function createServer(page, { id, name, description, serverConfig }) {
const result = await appServerCallFromPage(page, "mcpServer/create", {
server: {
id,
name,
description,
server_config: serverConfig,
enabled_lime: true,
enabled_claude: false,
enabled_codex: false,
enabled_gemini: false,
created_at: Date.now(),
},
});
assert(
Array.isArray(result.result?.servers),
"mcpServer/create 未返回 servers",
);
return result;
}
async function readRecentNotifications(page) {
return await page.evaluate(
async ({ command, notificationMethod }) => {
const invoke = window.electronAPI?.invoke;
if (typeof invoke !== "function") {
throw new Error("Electron preload invoke bridge is unavailable");
}
const response = await invoke(command, {
request: { includeRecent: true, limit: 100 },
});
const payload = response?.result ?? response;
const messages = Array.isArray(payload?.lines)
? payload.lines
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean)
: [];
return messages.filter(
(message) => message?.method === notificationMethod,
);
},
{
command: APP_SERVER_DRAIN_EVENTS_COMMAND,
notificationMethod: STARTUP_NOTIFICATION_METHOD,
},
);
}
function assertNotificationSequence(notifications, serverName, terminalStatus) {
const serverNotifications = notifications.filter(
(notification) => notification?.params?.name === serverName,
);
const statuses = serverNotifications.map(
(notification) => notification.params.status,
);
const startingIndex = statuses.indexOf("starting");
const terminalIndex = statuses.indexOf(terminalStatus);
assert(startingIndex >= 0, `${serverName} 缺少 starting notification`);
assert(
terminalIndex > startingIndex,
`${serverName}${terminalStatus} 未按顺序出现在 starting 之后`,
);
for (const notification of serverNotifications) {
assert(
JSON.stringify(Object.keys(notification.params).sort()) ===
JSON.stringify(
["error", "failureReason", "name", "status", "threadId"].sort(),
),
`${serverName} notification 字段漂移`,
);
assert(
notification.params.threadId === null,
`${serverName} threadId 应为 null`,
);
assert(
notification.params.failureReason === null,
`${serverName} failureReason 当前应为 null`,
);
}
return { statuses, serverNotifications };
}
function summarizeTrace(traceRaw) {
const trace = parseInvokeTraceRaw(traceRaw);
const requests = parseJsonRpcRequestsFromInvokeTrace(traceRaw);
const commands = [
...new Set(trace.map((entry) => entry?.command).filter(Boolean)),
];
const requestMethods = [
...new Set(requests.map((request) => request.method).filter(Boolean)),
];
const relevantEntries = trace.filter((entry) =>
[
APP_SERVER_HANDLE_JSON_LINES_COMMAND,
APP_SERVER_DRAIN_EVENTS_COMMAND,
].includes(entry?.command),
);
return {
commands,
requestMethods,
appServerHandleJsonLinesHitCount: relevantEntries.filter(
(entry) => entry?.command === APP_SERVER_HANDLE_JSON_LINES_COMMAND,
).length,
appServerDrainEventsHitCount: relevantEntries.filter(
(entry) => entry?.command === APP_SERVER_DRAIN_EVENTS_COMMAND,
).length,
electronIpcHitCount: relevantEntries.filter(
(entry) => entry?.transport === "electron-ipc",
).length,
mockFallbackHitCount: relevantEntries.filter(
(entry) => entry?.transport !== "electron-ipc",
).length,
failedInvokeCount: relevantEntries.filter(
(entry) => entry?.status !== "success",
).length,
missingRefreshMethods: REQUIRED_REFRESH_METHODS.filter(
(method) => !requestMethods.includes(method),
),
legacyMcpCommandsSeen: LEGACY_MCP_COMMANDS.filter((command) =>
commands.includes(command),
),
};
}
async function cleanupServer(page, id, name) {
if (!page || page.isClosed()) return;
await appServerCallFromPage(page, "mcpServer/stop", { name }).catch(
() => undefined,
);
await appServerCallFromPage(page, "mcpServer/delete", { id }).catch(
() => undefined,
);
}
export async function run() {
const options = parseMcpConfigFixtureArgs(process.argv.slice(2), {
defaults: DEFAULTS,
});
if (options.help) {
printHelp();
return;
}
fs.mkdirSync(options.evidenceDir, { recursive: true });
const summaryPath = path.join(
options.evidenceDir,
`${options.prefix}-summary.json`,
);
const screenshotPath = path.join(
options.evidenceDir,
`${options.prefix}.png`,
);
const failureScreenshotPath = path.join(
options.evidenceDir,
`${options.prefix}-failure.png`,
);
const runtimeEnv = createTempRuntimeEnv();
const appServerBinary = resolveDevAppServerBinary({
env: runtimeEnv.env,
repoRoot: process.cwd(),
forceBuild: false,
});
const appServerEnv = resolveElectronAppServerRuntimeEnv({
env: { ...runtimeEnv.env, APP_SERVER_BIN: appServerBinary },
});
const suffix = Date.now();
const readyServer = {
id: `mcp-startup-ready-${suffix}`,
name: `startup-ready-${suffix}`,
};
const failedServer = {
id: `mcp-startup-failed-${suffix}`,
name: `startup-failed-${suffix}`,
};
const consoleErrors = [];
const pageErrors = [];
const summary = {
ok: false,
checkedAt: new Date().toISOString(),
runId: options.runId,
proofLevel: "Gate B",
notificationMethod: STARTUP_NOTIFICATION_METHOD,
backendMode: "runtime",
electronPreloadBridge: false,
startingVisible: false,
readyVisible: false,
failedVisible: false,
notificationSequences: null,
trace: null,
consoleErrors,
pageErrors,
invokeErrorCount: 0,
screenshot: null,
tempRoot: options.keepTemp ? runtimeEnv.tempRoot : null,
failure: null,
};
let handle = null;
let page = null;
let fixture = null;
try {
logStage("write-delayed-mcp-fixture");
fixture = await writeMcpFixture({ initializeDelayMs: 1_200 });
logStage("launch-electron");
handle = await launchElectronFixture({
options,
runtimeEnv,
appServerEnv,
consoleErrors,
pageErrors,
backendMode: "runtime",
});
page = handle.page;
summary.electronPreloadBridge =
handle.rendererSnapshot.electron &&
handle.rendererSnapshot.hasInvokeBridge;
logStage("create-startup-servers");
await createServer(page, {
...readyServer,
description: "MCP startup ready notification fixture",
serverConfig: {
command: "node",
args: [fixture.serverPath],
cwd: fixture.root,
timeout: 5,
},
});
await createServer(page, {
...failedServer,
description: "MCP startup failed notification fixture",
serverConfig: {
command: path.join(runtimeEnv.tempRoot, "missing-mcp-command"),
args: [],
timeout: 3,
},
});
logStage("open-mcp-runtime-settings");
await openMcpRuntimeSettings(page, options);
const readyCard = serverCard(page, readyServer.name);
const failedCard = serverCard(page, failedServer.name);
await Promise.all([
readyCard.waitFor({ state: "visible", timeout: 45_000 }),
failedCard.waitFor({ state: "visible", timeout: 45_000 }),
]);
await page.evaluate(() => {
window.localStorage.removeItem("lime_invoke_error_buffer_v1");
window.localStorage.removeItem("lime_invoke_trace_buffer_v1");
});
logStage("start-ready-server");
const readyRequest = appServerCallFromPage(page, "mcpServer/start", {
name: readyServer.name,
});
await readyCard.getByText(STARTING_COPY).waitFor({
state: "visible",
timeout: Math.min(30_000, options.timeoutMs),
});
summary.startingVisible = true;
await readyRequest;
await readyCard.getByText(RUNNING_COPY).waitFor({
state: "visible",
timeout: Math.min(30_000, options.timeoutMs),
});
summary.readyVisible = true;
logStage("start-failed-server");
const failedResult = await appServerCallFromPage(page, "mcpServer/start", {
name: failedServer.name,
}).then(
() => ({ failed: false, error: null }),
(error) => ({
failed: true,
error: sanitizeText(
error instanceof Error ? error.message : String(error),
),
}),
);
assert(failedResult.failed, "失败 MCP server 意外启动成功");
await failedCard.getByText(LATEST_ERROR_COPY).waitFor({
state: "visible",
timeout: Math.min(30_000, options.timeoutMs),
});
summary.failedVisible = true;
logStage("read-recent-typed-notifications");
const notifications = await readRecentNotifications(page);
const readySequence = assertNotificationSequence(
notifications,
readyServer.name,
"ready",
);
const failedSequence = assertNotificationSequence(
notifications,
failedServer.name,
"failed",
);
const failedTerminal = failedSequence.serverNotifications.find(
(notification) => notification.params.status === "failed",
);
assert(
typeof failedTerminal?.params?.error === "string" &&
failedTerminal.params.error.length > 0,
"failed notification 缺少 error",
);
summary.notificationSequences = {
[readyServer.name]: readySequence.statuses,
[failedServer.name]: failedSequence.statuses,
};
const invokeEvidence = await page.evaluate(() => ({
traceRaw: window.localStorage.getItem("lime_invoke_trace_buffer_v1"),
errorRaw: window.localStorage.getItem("lime_invoke_error_buffer_v1"),
url: window.location.href,
locale: document.documentElement.lang,
electron: window.__LIME_ELECTRON__ === true,
hasInvokeBridge: typeof window.electronAPI?.invoke === "function",
}));
const trace = summarizeTrace(invokeEvidence.traceRaw);
assert(invokeEvidence.electron, "页面不是 Electron renderer");
assert(invokeEvidence.hasInvokeBridge, "preload invoke bridge 不可用");
assert(
trace.appServerHandleJsonLinesHitCount > 0,
"未观察到 App Server handle IPC",
);
assert(
trace.appServerDrainEventsHitCount > 0,
"未观察到 App Server event drain",
);
assert(
trace.missingRefreshMethods.length === 0,
"terminal notification 未触发刷新",
);
assert(trace.mockFallbackHitCount === 0, "观察到非 Electron IPC fallback");
assert(trace.failedInvokeCount === 0, "观察到 current bridge invoke 失败");
assert(trace.legacyMcpCommandsSeen.length === 0, "观察到 legacy MCP 命令");
const invokeErrors = parseInvokeTraceRaw(invokeEvidence.errorRaw);
assert(invokeErrors.length === 0, "观察到 invoke error buffer 记录");
assert(
consoleErrors.length === 0,
`观察到 console error: ${consoleErrors.join(" | ")}`,
);
assert(
pageErrors.length === 0,
`观察到 page error: ${pageErrors.join(" | ")}`,
);
await page.screenshot({ path: screenshotPath, fullPage: true });
summary.ok = true;
summary.completedAt = new Date().toISOString();
summary.pageUrl = invokeEvidence.url;
summary.documentLocale = invokeEvidence.locale;
summary.trace = trace;
summary.invokeErrorCount = invokeErrors.length;
summary.screenshot = path.basename(screenshotPath);
writeJsonFile(summaryPath, summary);
console.log(`${LOG_PREFIX} summary=${summaryPath}`);
} catch (error) {
summary.completedAt = new Date().toISOString();
summary.failure = sanitizeText(
error instanceof Error ? error.message : String(error),
);
if (page && !page.isClosed()) {
await page
.screenshot({ path: failureScreenshotPath, fullPage: true })
.catch(() => undefined);
summary.failureScreenshot = path.basename(failureScreenshotPath);
}
writeJsonFile(summaryPath, summary);
console.error(`${LOG_PREFIX} failed=${summary.failure}`);
console.error(`${LOG_PREFIX} summary=${summaryPath}`);
throw error;
} finally {
await cleanupServer(page, readyServer.id, readyServer.name);
await cleanupServer(page, failedServer.id, failedServer.name);
await closeElectronFixture(handle);
if (!options.keepTemp) {
if (fixture?.root)
fs.rmSync(fixture.root, { recursive: true, force: true });
fs.rmSync(runtimeEnv.tempRoot, { recursive: true, force: true });
}
}
}
const entryHref = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: null;
if (entryHref === import.meta.url) {
run().catch(() => {
process.exitCode = 1;
});
}

Some files were not shown because too many files have changed in this diff Show More