From d0971ea086653186114b45f4fa83452cc46d4baa Mon Sep 17 00:00:00 2001 From: coso Date: Sat, 1 Aug 2026 00:06:26 +0800 Subject: [PATCH] Release v1.118.0 --- .gitignore | 2 + RELEASE_NOTES.en.md | 42 +- RELEASE_NOTES.md | 42 +- electron/updateHost.test.ts | 132 +- electron/updateHost.ts | 284 +- electron/updateVersion.test.ts | 47 + electron/updateVersion.ts | 127 + internal/aiprompts/architecture.md | 36 + internal/aiprompts/commands.md | 30 +- internal/aiprompts/mcp.md | 6 +- internal/exec-plans/README.md | 2 + .../core-user-flow-e2e-audit-2026-07-31.md | 193 + .../exec-plans/mcp-modernization-progress.md | 22 +- .../exec-plans/refactor-v2-implementation.md | 104 +- internal/exec-plans/release-v1.118.0-plan.md | 58 + ...s-model-catalog-and-updater-reliability.md | 67 + internal/refactor/v2/EVENT-PROJECTIONS.md | 12 +- internal/refactor/v2/IMPLEMENTATION-PLAN.md | 100 +- lime-rs/Cargo.lock | 61 +- lime-rs/Cargo.toml | 2 +- .../json/app_server_protocol.schemas.json | 3559 ++++++++++++++++- .../schema/json/manifest.json | 24 + .../schema/json/v2/CodexErrorInfo.json | 144 + .../schema/json/v2/ErrorNotification.json | 198 + ...ServerOauthLoginCompletedNotification.json | 31 + .../schema/json/v2/NonSteerableTurnKind.json | 9 + .../schema/json/v2/ServerNotification.json | 331 +- .../json/v2/SkillsChangedNotification.json | 7 + .../schema/json/v2/Thread.json | 152 +- .../schema/json/v2/ThreadForkResponse.json | 152 +- .../schema/json/v2/ThreadListResponse.json | 152 +- .../json/v2/ThreadMetadataUpdateResponse.json | 152 +- .../schema/json/v2/ThreadReadResponse.json | 152 +- .../schema/json/v2/ThreadResumeResponse.json | 152 +- .../schema/json/v2/ThreadSearchResponse.json | 152 +- .../schema/json/v2/ThreadSearchResult.json | 152 +- .../schema/json/v2/ThreadStartResponse.json | 152 +- .../json/v2/ThreadStartedNotification.json | 152 +- .../json/v2/ThreadTurnsListResponse.json | 152 +- .../json/v2/ThreadUnarchiveResponse.json | 152 +- .../schema/json/v2/Turn.json | 152 +- .../json/v2/TurnCompletedNotification.json | 152 +- .../schema/json/v2/TurnError.json | 154 +- .../schema/json/v2/TurnPlanStep.json | 28 + .../schema/json/v2/TurnPlanStepStatus.json | 10 + .../json/v2/TurnPlanUpdatedNotification.json | 59 + .../schema/json/v2/TurnStartResponse.json | 152 +- .../json/v2/TurnStartedNotification.json | 152 +- .../schema/json/v2/TurnsPage.json | 152 +- .../src/protocol/v2/common.rs | 46 +- .../src/protocol/v2/envelopes.rs | 67 +- .../src/protocol/v2/mcp.rs | 58 + .../src/protocol/v2/methods.rs | 8 + .../src/protocol/v2/notification.rs | 43 + .../src/protocol/v2/schema_types.rs | 8 + .../src/protocol/v2/tests.rs | 154 + .../src/schema_export/registry.rs | 10 + lime-rs/crates/app-server/Cargo.toml | 1 + lime-rs/crates/app-server/src/lib.rs | 9 + .../src/local_data_source/impls/mcp.rs | 4 +- .../app-server/src/local_data_source/mcp.rs | 7 +- .../app-server/src/local_data_source/tests.rs | 50 + .../crates/app-server/src/processor/mcp.rs | 32 +- .../crates/app-server/src/processor/skill.rs | 55 +- .../processor/tests/event_notifications.rs | 255 +- .../app-server/src/processor/tests/mcp.rs | 165 +- .../src/processor/thread/projection.rs | 4 +- .../src/processor/v2_notifications.rs | 71 +- .../src/processor/v2_notifications/error.rs | 167 + .../processor/v2_notifications/turn_plan.rs | 97 + .../app-server/src/runtime/app_data/mcp.rs | 4 +- .../app-server/src/runtime/load_context.rs | 14 +- lime-rs/crates/app-server/src/runtime/mcp.rs | 2 +- .../app-server/src/runtime/read_model.rs | 9 +- .../src/runtime/read_model/tests.rs | 41 + .../src/runtime_backend/plan_events.rs | 111 +- .../src/runtime_backend/tool_events.rs | 74 +- .../crates/app-server/src/skills_watcher.rs | 296 ++ lime-rs/crates/mcp/src/events.rs | 6 - lime-rs/crates/mcp/src/lib.rs | 7 +- lime-rs/crates/mcp/src/manager.rs | 6 +- lime-rs/crates/mcp/src/oauth.rs | 107 +- lime-rs/crates/mcp/src/oauth_tests.rs | 170 +- lime-rs/crates/skills/src/agent_snapshot.rs | 7 + lime-rs/crates/skills/src/lib.rs | 7 +- package.json | 3 +- packages/agent-runtime-client/README.md | 7 +- packages/agent-runtime-client/src/index.ts | 2 + .../agent-runtime-client/src/runtimeClient.ts | 39 +- .../src/sessionGateway.ts | 49 +- .../tests/client.test.mjs | 73 + .../app-server-client/src/agent-runtime.ts | 67 +- .../src/generated/protocol-types.ts | 109 +- .../src/server-notifications.ts | 200 + .../app-server-client/tests/client.test.mjs | 78 + .../tests/direct-notifications.test.mjs | 224 +- packages/lime-cli-npm/package.json | 2 +- ...chat-current-fixture-assertion-context.mjs | 123 +- .../claw-chat-current-fixture-assertions.mjs | 18 +- ...claw-chat-current-fixture-backend-file.mjs | 122 +- ...aw-chat-current-fixture-backend-ledger.mjs | 21 + ...aw-chat-current-fixture-backend-script.mjs | 186 + ...chat-current-fixture-common-assertions.mjs | 549 +-- ...current-fixture-common-assertions.test.mjs | 166 + .../claw-chat-current-fixture-constants.mjs | 39 + ...-current-fixture-inputbar-rich-restore.mjs | 80 +- ...rent-fixture-not-applicable-assertions.mjs | 3 + ...-chat-current-fixture-read-model-waits.mjs | 1 + .../claw-chat-current-fixture-rpc.mjs | 1 + ...at-current-fixture-scenario-assertions.mjs | 695 ++-- ...law-chat-current-fixture-scenario-flow.mjs | 34 + ...rent-fixture-skills-runtime-assertions.mjs | 37 +- ...at-current-fixture-skills-runtime-flow.mjs | 8 + ...-chat-current-fixture-skills-workspace.mjs | 237 +- ...nt-fixture-smoke-skills-runtime-guards.mjs | 16 + .../claw-chat-current-fixture-smoke.mjs | 48 +- .../claw-chat-current-fixture-smoke.test.mjs | 165 + ...-chat-current-fixture-turn-plan-update.mjs | 445 +++ .../claw-chat-current-fixture-typed-error.mjs | 382 ++ .../current-fixture-regression-smoke.mjs | 31 +- .../current-fixture-regression-smoke.test.mjs | 22 + .../skills-runtime-fixture-scenario.mjs | 5 + scripts/check-app-server-client-contract.mjs | 66 + .../mcp-oauth-notification-fixture-smoke.mjs | 535 +++ scripts/generate-protocol-types.mjs | 3 + scripts/mcp/lib/contract-guards.mjs | 83 +- src/RootRouter.test.tsx | 6 +- src/RootRouter.tsx | 5 + .../AppSidebar.preferences.test.tsx | 40 +- .../MessageListRuntimeStatus.test.tsx | 1 + .../components/MessageListRuntimeStatus.tsx | 2 + ...TaskCenterUtilityToolbar.planRail.test.tsx | 77 +- .../components/TaskCenterUtilityToolbar.tsx | 22 +- .../chat/hooks/agentStreamPreparedSendEnv.ts | 2 + .../chat/hooks/agentStreamRuntimeHandler.ts | 41 + ...agentStreamRuntimeHandler.turnPlan.test.ts | 71 + ...entStreamRuntimeHandler.typedError.test.ts | 156 + .../hooks/agentStreamRuntimeHandlerTypes.ts | 2 + .../chat/hooks/agentStreamSubmitExecution.ts | 4 + .../chat/hooks/agentStreamSubmitFailure.ts | 9 +- .../chat/hooks/agentStreamTurnEventBinding.ts | 38 +- .../hooks/agentStreamTypedErrorController.ts | 51 + ...entStreamTypedErrorController.unit.test.ts | 58 + ...amTypedErrorOwnership.currentGuard.test.ts | 31 + .../hooks/agentStreamUserInputSubmission.ts | 1 + .../agent/chat/hooks/useAgentChat.ts | 1 + .../agent/chat/hooks/useAgentSession.ts | 2 + .../agent/chat/hooks/useAgentStream.ts | 5 + .../agent/chat/hooks/useLimeSkills.test.tsx | 62 +- .../agent/chat/hooks/useLimeSkills.ts | 32 +- .../runtimeLifecycleProjection.test.ts | 28 + .../projection/runtimeLifecycleProjection.ts | 5 +- .../agentRuntimeErrorPresentation.test.ts | 29 + .../utils/agentRuntimeErrorPresentation.ts | 18 + .../utils/providerModelCompatibility.test.ts | 13 + .../chat/utils/providerModelCompatibility.ts | 3 + .../useWorkspaceSendActions.test.tsx | 6 + .../chat/workspace/useWorkspaceSendActions.ts | 3 +- .../ApiKeyProviderSection.ui.test.tsx | 48 +- .../api-key-provider/ModelAddPanel.tsx | 27 +- src/components/app-sidebar/AppUpdateEntry.tsx | 11 +- .../input-kit/ModelSelector.test.tsx | 50 + src/components/input-kit/ModelSelector.tsx | 41 +- .../settings-v2/system/about/index.test.tsx | 37 +- .../settings-v2/system/about/index.tsx | 24 +- src/components/ui/sonner.test.tsx | 76 + src/hooks/useConfiguredProviders.test.ts | 25 + src/hooks/useConfiguredProviders.ts | 13 +- src/hooks/useMcp.test.tsx | 102 +- src/hooks/useMcpEvents.ts | 75 +- src/i18n/resources/en-US/common.json | 3 +- src/i18n/resources/en-US/settings.json | 4 +- src/i18n/resources/ja-JP/common.json | 3 +- src/i18n/resources/ja-JP/settings.json | 4 +- src/i18n/resources/ko-KR/common.json | 3 +- src/i18n/resources/ko-KR/settings.json | 4 +- src/i18n/resources/zh-CN/common.json | 3 +- src/i18n/resources/zh-CN/settings.json | 4 +- src/i18n/resources/zh-TW/common.json | 3 +- src/i18n/resources/zh-TW/settings.json | 4 +- src/lib/api/agentProtocolContentParsers.ts | 45 + .../agentProtocolEventParser.turnPlan.test.ts | 34 + src/lib/api/agentProtocolEventTypes.ts | 13 + src/lib/api/agentProtocolRuntimeParsers.ts | 8 + ...appServerCanonicalThreadProjection.test.ts | 155 +- .../appServerCanonicalThreadProjection.ts | 107 +- .../appServerEventStreamProjection.ts | 19 +- ...pServerTypedErrorNotification.unit.test.ts | 90 + .../appServerV2Notification.test.ts | 50 + .../agentRuntime/appServerV2Notification.ts | 82 + src/lib/api/agentRuntime/eventSequenceGate.ts | 6 +- src/lib/api/runtimeSkillCatalog.ts | 31 + src/lib/api/runtimeSkillCatalog.unit.test.ts | 80 + src/lib/api/skills.test.ts | 40 + src/lib/api/skills.ts | 8 + src/pages/update-notification.test.tsx | 64 + src/pages/update-notification.tsx | 54 +- 197 files changed, 16361 insertions(+), 1566 deletions(-) create mode 100644 electron/updateVersion.test.ts create mode 100644 electron/updateVersion.ts create mode 100644 internal/exec-plans/core-user-flow-e2e-audit-2026-07-31.md create mode 100644 internal/exec-plans/release-v1.118.0-plan.md create mode 100644 internal/exec-plans/windows-model-catalog-and-updater-reliability.md create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/CodexErrorInfo.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/ErrorNotification.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/McpServerOauthLoginCompletedNotification.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/NonSteerableTurnKind.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/SkillsChangedNotification.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/TurnPlanStep.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/TurnPlanStepStatus.json create mode 100644 lime-rs/crates/app-server-protocol/schema/json/v2/TurnPlanUpdatedNotification.json create mode 100644 lime-rs/crates/app-server/src/processor/v2_notifications/error.rs create mode 100644 lime-rs/crates/app-server/src/processor/v2_notifications/turn_plan.rs create mode 100644 lime-rs/crates/app-server/src/skills_watcher.rs create mode 100644 scripts/agent-runtime/claw-chat-current-fixture-common-assertions.test.mjs create mode 100644 scripts/agent-runtime/claw-chat-current-fixture-turn-plan-update.mjs create mode 100644 scripts/agent-runtime/claw-chat-current-fixture-typed-error.mjs create mode 100644 scripts/electron/mcp-oauth-notification-fixture-smoke.mjs create mode 100644 src/components/agent/chat/hooks/agentStreamRuntimeHandler.turnPlan.test.ts create mode 100644 src/components/agent/chat/hooks/agentStreamRuntimeHandler.typedError.test.ts create mode 100644 src/components/agent/chat/hooks/agentStreamTypedErrorController.ts create mode 100644 src/components/agent/chat/hooks/agentStreamTypedErrorController.unit.test.ts create mode 100644 src/components/agent/chat/hooks/agentStreamTypedErrorOwnership.currentGuard.test.ts create mode 100644 src/components/ui/sonner.test.tsx create mode 100644 src/lib/api/agentProtocolEventParser.turnPlan.test.ts create mode 100644 src/lib/api/agentRuntime/appServerTypedErrorNotification.unit.test.ts create mode 100644 src/lib/api/runtimeSkillCatalog.ts create mode 100644 src/lib/api/runtimeSkillCatalog.unit.test.ts diff --git a/.gitignore b/.gitignore index 0663db58a..f84cded76 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,8 @@ internal/exec-plans/* !internal/exec-plans/right-surface-implementation-progress.md !internal/exec-plans/agent-app-host-v3-implementation.md !internal/exec-plans/windows-runtime-model-route-repair-plan.md +!internal/exec-plans/windows-model-catalog-and-updater-reliability.md +!internal/exec-plans/core-user-flow-e2e-audit-2026-07-31.md !internal/exec-plans/agent-app-uninstall-current-ui-progress.md !internal/exec-plans/writing-v2-workflow-completion-plan.md !internal/exec-plans/workflow-standardization-plan.md diff --git a/RELEASE_NOTES.en.md b/RELEASE_NOTES.en.md index 610281a70..0957012a1 100644 --- a/RELEASE_NOTES.en.md +++ b/RELEASE_NOTES.en.md @@ -1,41 +1,43 @@ -## Lime v1.117.0 +## Lime v1.118.0 The Simplified Chinese release notes are the primary version. This English page is a companion for international readers. ### New Features -- Completed the `currentTime/read`, `item/permissions/requestApproval`, and `item/tool/call` host capabilities, keeping clock reads, permission escalation, and desktop dynamic-tool calls on the same Electron Host, App Server JSON-RPC, and RuntimeCore product chain. -- Promoted dynamic-tool calls to canonical Thread Items with namespace, tool, raw arguments, ordered text/image/audio output, status, and duration, preserving the lifecycle across session restoration. -- Completed the six AgentControl tools, typed wait states, child-agent activity projection, and cold-restart restoration for Multi-Agent conversations; parent-owned child threads now explicitly reject direct input. -- Added canonical execution recovery for known Lime Hub models through the bundled registry and canonical Agnes 2.5 Flash/Pro catalog entries, while unknown models remain fail-closed. +- 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. ### Fixes -- Fixed clean-environment resolution of `@limecloud/app-server-client/browser`, image-task fixtures using an invalid Chat model, model-list generation baseline drift, and incorrect JSON-RPC codes for invalid model parameters. -- Fixed model selectors exposing inferred-only models that RuntimeCore cannot execute, live provider metadata being overwritten by local hints, and persisted Windows Lime Hub models failing on the first Turn. -- Fixed media previews depending on transient chunk/completed notifications and Renderer live-drain helpers; every ranged response now validates offset, length, digest, and size before advancing progress. -- Fixed boundary cases in initial-session and child-agent navigation, canonical Item reads, event sequencing, and pending-interaction restoration. +- 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. ### Improvements and Refactors -- Migrated `configWarning` from v0 DTOs and schemas to one typed v2 owner; the Renderer only projects decoded global warnings and shows deduplicated messages across all five locales. -- Removed the v0 config-warning and TextPosition/TextRange schemas, media transient-notification consumers, the old protocol facade, and duplicate preview synchronization modules without production compatibility wrappers. -- Tightened permission-profile, sandbox-command, dynamic-tool routing, provider-history, and read-model boundaries; reserved names, schema collisions, identity mismatches, duplicate responses, and late responses all fail closed. -- Switched Skill installation locations to the authoritative backend `localDirectoryPath`, using Electron `openPath` for directories and `showItemInFolder` for files. +- 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. ### Tests and Quality -- Added Rust, Electron, protocol, and Renderer regressions for host capabilities, dynamic tools, permission profiles, current time, AgentControl, parent-owned threads, v2 config warnings, and model routing. -- Added real Electron Gate B evidence covering preload/IPC, `app_server_handle_json_lines`, App Server, RuntimeCore, canonical read models, visible GUI state, and cold-restart identity. -- Strengthened guards for the app-server-client browser subpath, generated protocols, five-locale resources, legacy surfaces, and the current bridge. +- 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. ### Documentation -- Updated architecture sources for host capabilities, the canonical DynamicTool payload, v2 config warnings, media transient retirement, and Lime Hub capability provenance. -- Updated Refactor v2 V2-04/V2-05 progress, Gate B evidence, the Windows runtime model-routing plan, and the Soul output-surface roadmap. +- 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. ### Other -- Bumped the root app, CLI npm package, Rust workspace, and lockfile versions to `1.117.0`. +- 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. -**Full changes**: `v1.116.0` -> `v1.117.0` +**Full changes**: `v1.117.0` -> `v1.118.0` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c31c84ab5..7b1fc3d0a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,39 +1,41 @@ -## Lime v1.117.0 +## Lime v1.118.0 ### 新功能 -- 完成 `currentTime/read`、`item/permissions/requestApproval` 与 `item/tool/call` 三项 Host capability:系统时间读取、权限提升和桌面动态工具调用都经 Electron Host、App Server JSON-RPC 与 RuntimeCore 的同一条产品链闭环。 -- 动态工具调用升级为 canonical Thread Item,完整保留 namespace、tool、原始参数、有序文本/图片/音频输出、状态和耗时;恢复会话后仍能可靠呈现工具生命周期。 -- Multi-Agent 对话补齐六项 AgentControl 工具、typed wait state、子 Agent 活动投影和冷重启恢复;parent-owned 子线程现在明确禁止直接输入。 -- Lime Hub 可通过 bundled canonical registry 恢复已知模型的执行能力,并新增 Agnes 2.5 Flash/Pro canonical catalog;未知模型仍保持 fail-closed。 +- 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。 ### 修复 -- 修复干净环境无法解析 `@limecloud/app-server-client/browser`、图片任务 fixture 使用错误 Chat 模型、模型列表 generation 基线漂移,以及非法模型参数返回错误 JSON-RPC code 的问题。 -- 修复模型选择器展示 RuntimeCore 无法执行的 inferred-only 模型、live provider metadata 被本地 hint 覆盖,以及 Windows Lime Hub 已保存模型无法进入首回合的问题。 -- 修复媒体预览依赖 transient chunk/completed 通知和 Renderer live-drain helper 的双轨行为;所有分段读取现在先校验 offset、length、digest 与大小再更新进度。 -- 修复初始会话和子 Agent 导航、canonical Item 读取、事件序列门控与待处理交互恢复中的边界问题。 +- 修复 Windows 用户已显式配置的 Provider 模型因缺少权威 capability 来源而不出现在聊天模型选择器中的问题;仅靠启发式推断且不可执行的模型继续 fail closed。 +- 修复 Electron updater 将同版本、旧版本、无效版本或缺失版本误报为可安装更新的问题;只有严格更高的语义版本进入下载和安装流程。 +- 修复更新失败、已是最新和发现新版本共用提示的问题,并统一侧边栏、关于页与更新通知页的下载、重试和重启安装状态。 +- 修复 Provider 路由不可执行错误在发送入口和 runtime 事件链中出现不一致提示,以及部分独立页面缺少全局 toast 宿主的问题。 +- 修复 Provider 添加流程在尚未保存配置时显示“完成添加”,并在已保存后正确返回可用模型上下文。 ### 优化与重构 -- 将 `configWarning` 从 v0 DTO/schema 迁移到唯一的 typed v2 owner,Renderer 只投影解码后的全局告警,并提供五语言去重提示。 -- 删除 v0 配置告警、TextPosition/TextRange schema、媒体 transient notification 消费者、旧协议 facade 与重复预览同步模块,不保留生产兼容包装。 -- 收紧请求权限 profile、沙箱命令、动态工具路由、provider history 与 read model 的类型边界;保留名、schema 冲突、身份错配、重复或迟到响应全部 fail-closed。 -- Skill 安装目录改用后端权威 `localDirectoryPath`,Electron 对目录使用 `openPath`、对文件使用 `showItemInFolder`。 +- 将 `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` 状态转换。 ### 测试与质量 -- 新增 Host capability、动态工具、权限 profile、current time、AgentControl、parent-owned thread、配置告警 v2 与模型路由的 Rust、Electron、协议和 Renderer 回归。 -- 补充真实 Electron Gate B 证据,覆盖 preload/IPC、`app_server_handle_json_lines`、App Server、RuntimeCore、canonical read model、GUI 可见状态和冷重启 identity。 -- 加强 app-server-client browser 子路径、协议生成物、五语言资源、legacy surface 和 current bridge 的守卫。 +- 新增 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 命中为零。 ### 文档 -- 更新 Host capability、DynamicTool canonical payload、配置告警 v2、媒体 transient retirement 与 Lime Hub capability provenance 的架构事实源。 -- 更新 Refactor v2 V2-04/V2-05 进度、Gate B evidence、Windows runtime 模型路由计划和 Soul 输出面路线图。 +- 更新 App Server v2 notification、Skills catalog invalidation、MCP OAuth、typed error/plan recovery 与 updater/模型目录的架构和执行事实源。 +- 记录 Windows 模型目录与更新可靠性、核心用户主流程 E2E 审计,以及 Refactor v2 V2-05 当前完成情况。 ### 其他 -- 将根应用、CLI npm 包、Rust workspace 与锁文件版本统一提升到 `1.117.0`。 +- 将根应用、CLI npm 包、Rust workspace 与锁文件版本统一提升到 `1.118.0`。 +- Windows Squirrel 从 N-1 版本发现、下载、重启安装的 packaged Gate B 仍需 Windows 实机验收;macOS Electron 证据不替代该平台验证。 -**完整变更**: `v1.116.0` -> `v1.117.0` +**完整变更**: `v1.117.0` -> `v1.118.0` diff --git a/electron/updateHost.test.ts b/electron/updateHost.test.ts index a06de0375..cc713b83e 100644 --- a/electron/updateHost.test.ts +++ b/electron/updateHost.test.ts @@ -74,6 +74,17 @@ vi.mock("./electronRuntime", () => ({ import { ElectronUpdateHost } from "./updateHost"; +function emitDownloadedUpdate(releaseName: string | undefined): void { + autoUpdaterEmit( + "update-downloaded", + {}, + "release notes", + releaseName, + new Date("2026-07-17T00:00:00.000Z"), + "https://updates.limecloud.com/Lime-update.zip", + ); +} + describe("ElectronUpdateHost", () => { beforeEach(() => { appState.isPackaged = false; @@ -196,22 +207,18 @@ describe("ElectronUpdateHost", () => { }); const host = new ElectronUpdateHost(vi.fn()); - await expect(host.invoke("check_for_updates")).resolves.toEqual( - expect.objectContaining({ hasUpdate: true }), - ); + const checkSession = host.invoke("check_for_updates"); + await Promise.resolve(); expect(checkForUpdatesMock).toHaveBeenCalledTimes(1); await expect(host.invoke("get_update_install_session")).resolves.toEqual( expect.objectContaining({ stage: "downloading" }), ); const installSession = host.invoke("start_update_install_session"); - autoUpdaterEmit( - "update-downloaded", - {}, - "release notes", - "1.61.0", - new Date("2026-07-17T00:00:00.000Z"), - "https://updates.limecloud.com/Lime-1.61.0.zip", + emitDownloadedUpdate("1.61.0"); + + await expect(checkSession).resolves.toEqual( + expect.objectContaining({ hasUpdate: true, latest: "1.61.0" }), ); await expect(installSession).resolves.toEqual( @@ -236,12 +243,14 @@ describe("ElectronUpdateHost", () => { }); const host = new ElectronUpdateHost(vi.fn()); - await expect( - host.invoke("check_for_updates", { automatic: true }), - ).resolves.toEqual(expect.objectContaining({ hasUpdate: true })); - await expect( - host.invoke("check_for_updates", { automatic: true }), - ).resolves.toEqual(expect.objectContaining({ hasUpdate: true })); + const firstCheck = host.invoke("check_for_updates", { automatic: true }); + const secondCheck = host.invoke("check_for_updates", { automatic: true }); + emitDownloadedUpdate("1.61.0"); + + await expect(Promise.all([firstCheck, secondCheck])).resolves.toEqual([ + expect.objectContaining({ hasUpdate: true }), + expect.objectContaining({ hasUpdate: true }), + ]); expect(checkForUpdatesMock).toHaveBeenCalledTimes(1); }); @@ -255,28 +264,89 @@ describe("ElectronUpdateHost", () => { }); const host = new ElectronUpdateHost(vi.fn()); - await host.invoke("check_for_updates"); + const checkSession = host.invoke("check_for_updates"); const firstInstall = host.invoke("start_update_install_session"); const secondInstall = host.invoke("start_update_install_session"); - autoUpdaterEmit( - "update-downloaded", - {}, - "release notes", - "1.61.0", - new Date("2026-07-17T00:00:00.000Z"), - "https://updates.limecloud.com/Lime-1.61.0.zip", - ); + emitDownloadedUpdate("1.61.0"); - await expect(Promise.all([firstInstall, secondInstall])).resolves.toEqual( - [ - expect.objectContaining({ stage: "restarting" }), - expect.objectContaining({ stage: "restarting" }), - ], - ); + await expect( + Promise.all([checkSession, firstInstall, secondInstall]), + ).resolves.toEqual([ + expect.objectContaining({ hasUpdate: true }), + expect.objectContaining({ stage: "restarting" }), + expect.objectContaining({ stage: "restarting" }), + ]); await vi.advanceTimersByTimeAsync(250); expect(quitAndInstallMock).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); + + it.each(["1.60.0", "v1.60.0", "1.59.0"])( + "候选版本 %s 不高于当前版本时不得进入安装", + async (candidateVersion) => { + vi.useFakeTimers(); + try { + appState.isPackaged = true; + checkForUpdatesMock.mockImplementation(() => { + autoUpdaterEmit("update-available"); + }); + const host = new ElectronUpdateHost(vi.fn()); + + const checkSession = host.invoke("check_for_updates"); + const installSession = host.invoke("start_update_install_session"); + emitDownloadedUpdate(candidateVersion); + + await expect(checkSession).resolves.toEqual( + expect.objectContaining({ + hasUpdate: false, + latest: candidateVersion.replace(/^v/i, ""), + }), + ); + await expect(installSession).resolves.toEqual( + expect.objectContaining({ stage: "up_to_date" }), + ); + await vi.advanceTimersByTimeAsync(250); + expect(quitAndInstallMock).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }, + ); + + it.each([undefined, "latest"])( + "候选版本 %s 无效时应终止更新且不得安装", + async (candidateVersion) => { + vi.useFakeTimers(); + try { + appState.isPackaged = true; + checkForUpdatesMock.mockImplementation(() => { + autoUpdaterEmit("update-available"); + }); + const host = new ElectronUpdateHost(vi.fn()); + + const checkSession = host.invoke("check_for_updates"); + const installSession = host.invoke("start_update_install_session"); + emitDownloadedUpdate(candidateVersion); + + await expect(checkSession).resolves.toEqual( + expect.objectContaining({ + hasUpdate: false, + error: "更新清单未提供有效的候选版本", + }), + ); + await expect(installSession).resolves.toEqual( + expect.objectContaining({ + stage: "failed", + latestVersion: null, + }), + ); + await vi.advanceTimersByTimeAsync(250); + expect(quitAndInstallMock).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }, + ); }); diff --git a/electron/updateHost.ts b/electron/updateHost.ts index 6aa378ebe..600876fc6 100644 --- a/electron/updateHost.ts +++ b/electron/updateHost.ts @@ -1,4 +1,9 @@ import { app, autoUpdater } from "./electronRuntime"; +import { + assessUpdateCandidate, + normalizeUpdateVersion, + type UpdateCandidateAssessment, +} from "./updateVersion"; type UpdateInstallStage = | "idle" @@ -84,7 +89,6 @@ export class ElectronUpdateHost { readonly #windowController: UpdateWindowController; #configured = false; #checkPromise: Promise | null = null; - #downloadPromise: Promise | null = null; #session: UpdateInstallSession; #latestInfo: UpdateInfo | null = null; @@ -156,27 +160,40 @@ export class ElectronUpdateHost { }; } this.#configure(); - if (this.#hasPendingUpdate()) { + if (this.#hasConfirmedUpdate()) { return this.#currentVersionInfo(); } - this.#setSession({ - stage: "checking", - message: "正在检查更新", - isActive: true, - }); + if (!this.#checkPromise) { + this.#setSession({ + stage: "checking", + latestVersion: null, + downloadUrl: null, + error: null, + message: "正在检查更新", + isActive: true, + completedAt: null, + }); + } try { const info = await this.#checkForUpdatesOnce(); this.#latestInfo = info; - const hasUpdate = Boolean(info && info.version !== app.getVersion()); + const assessment = this.#assessUpdateInfo(info); + if (assessment.status === "invalid") { + throw new Error("更新清单未提供有效的候选版本"); + } + const hasUpdate = assessment.status === "newer"; this.#setSession({ - stage: hasUpdate ? "downloading" : "up_to_date", - latestVersion: info?.version ?? null, - message: hasUpdate ? "发现新版本,正在下载更新" : "当前已是最新版本", - isActive: hasUpdate, - completedAt: hasUpdate ? null : Date.now(), + stage: hasUpdate ? "completed" : "up_to_date", + latestVersion: assessment.version, + downloadUrl: hasUpdate ? this.#downloadUrl(info) : null, + percent: hasUpdate ? 1 : 0, + error: null, + message: hasUpdate ? "更新已下载,准备安装" : "当前已是最新版本", + isActive: false, + completedAt: Date.now(), }); - return this.#versionInfo(info, hasUpdate); + return this.#versionInfo(info); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.#setSession({ @@ -204,13 +221,6 @@ export class ElectronUpdateHost { } this.#configure(); - if (this.#session.stage === "downloading") { - return { - success: true, - message: "更新下载已开始", - filePath: null, - }; - } if (this.#session.stage === "completed") { return { success: true, @@ -226,50 +236,19 @@ export class ElectronUpdateHost { }; } - this.#setSession({ - stage: "checking", - latestVersion: this.#latestInfo?.version ?? null, - message: "正在检查并下载更新", - isActive: true, - }); - try { - const info = await this.#checkForUpdatesOnce(); - if (!info) { - this.#setSession({ - stage: "up_to_date", - message: "当前已是最新版本", - isActive: false, - completedAt: Date.now(), - }); - return { - success: false, - message: "当前已是最新版本", - filePath: null, - }; - } - this.#latestInfo = info; - this.#setSession({ - stage: "downloading", - latestVersion: info.version ?? null, - message: "更新下载已开始", - isActive: true, - }); + const versionInfo = await this.checkForUpdates(); + if (versionInfo.hasUpdate) { return { success: true, - message: "更新下载已开始", + message: "更新已下载,准备安装", filePath: null, }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.#setSession({ - stage: "failed", - error: message, - message, - isActive: false, - completedAt: Date.now(), - }); - return { success: false, message }; } + return { + success: false, + message: versionInfo.error || "当前已是最新版本", + filePath: null, + }; } async startInstallSession(): Promise { @@ -292,30 +271,48 @@ export class ElectronUpdateHost { return this.#session; } if (this.#session.stage !== "completed") { - if (this.#session.stage !== "downloading") { + if (!this.#checkPromise) { this.#setSession({ stage: "checking", + latestVersion: null, + downloadUrl: null, + error: null, message: "正在检查并下载更新", isActive: true, + completedAt: null, }); - const info = await this.#checkForUpdatesOnce(); - if (!info) { - this.#setSession({ - stage: "up_to_date", - message: "当前已是最新版本", - isActive: false, - completedAt: Date.now(), - }); - return this.#session; - } } - await this.#waitForDownloadedUpdate(); + const info = await this.#checkForUpdatesOnce(); + this.#latestInfo = info; + const assessment = this.#assessUpdateInfo(info); + if (assessment.status === "invalid") { + throw new Error("更新清单未提供有效的候选版本"); + } + if (assessment.status !== "newer") { + this.#setSession({ + stage: "up_to_date", + latestVersion: assessment.version, + downloadUrl: null, + percent: 0, + error: null, + message: "当前已是最新版本", + isActive: false, + completedAt: Date.now(), + }); + return this.#session; + } } if (this.#isInstallingUpdate()) { return this.#session; } + const installAssessment = this.#assessUpdateInfo(this.#latestInfo); + if (installAssessment.status !== "newer") { + throw new Error("更新版本未通过安装前校验"); + } this.#setSession({ stage: "restarting", + latestVersion: installAssessment.version, + error: null, message: "正在重启并安装更新", isActive: true, canCloseWindow: false, @@ -350,18 +347,25 @@ export class ElectronUpdateHost { ), }); autoUpdater.on("update-available", () => { - this.#latestInfo = {}; + this.#latestInfo = null; this.#setSession({ stage: "downloading", latestVersion: null, downloadUrl: null, - message: "发现新版本,正在下载更新", + error: null, + message: "正在下载并验证更新", isActive: true, + completedAt: null, }); }); autoUpdater.on("update-not-available", () => { + this.#latestInfo = null; this.#setSession({ stage: "up_to_date", + latestVersion: null, + downloadUrl: null, + percent: 0, + error: null, message: "当前已是最新版本", isActive: false, completedAt: Date.now(), @@ -376,16 +380,7 @@ export class ElectronUpdateHost { releaseDate, updateURL, ); - this.#latestInfo = info; - this.#setSession({ - stage: "completed", - latestVersion: info.version ?? null, - downloadUrl: updateURL || null, - percent: 1, - message: "更新已下载,准备安装", - isActive: false, - completedAt: Date.now(), - }); + this.#applyDownloadedUpdate(info); }, ); autoUpdater.on("error", (error) => { @@ -419,12 +414,14 @@ export class ElectronUpdateHost { return app.isPackaged; } - #versionInfo(info: UpdateInfo | null, hasUpdate: boolean): VersionInfo { + #versionInfo(info: UpdateInfo | null): VersionInfo { + const assessment = this.#assessUpdateInfo(info); + const hasUpdate = assessment.status === "newer"; return { current: app.getVersion(), - latest: info?.version ?? null, + latest: assessment.version, hasUpdate, - downloadUrl: this.#downloadUrl(info), + downloadUrl: hasUpdate ? this.#downloadUrl(info) : null, releaseNotes: typeof info?.releaseNotes === "string" ? info.releaseNotes : null, releaseNotesUrl: null, @@ -434,22 +431,15 @@ export class ElectronUpdateHost { } #currentVersionInfo(): VersionInfo { - const hasUpdate = Boolean( - this.#latestInfo && this.#latestInfo.version !== app.getVersion(), - ); - return this.#versionInfo(this.#latestInfo, hasUpdate); + return this.#versionInfo(this.#latestInfo); } #downloadUrl(info: UpdateInfo | null): string | null { return info?.updateURL ?? null; } - #hasPendingUpdate(): boolean { - return ( - this.#session.stage === "downloading" || - this.#session.stage === "completed" || - this.#isInstallingUpdate() - ); + #hasConfirmedUpdate(): boolean { + return this.#session.stage === "completed" || this.#isInstallingUpdate(); } #isInstallingUpdate(): boolean { @@ -464,48 +454,6 @@ export class ElectronUpdateHost { return await this.#checkPromise; } const promise = new Promise((resolve, reject) => { - const cleanup = () => { - autoUpdater.removeListener("update-available", onAvailable); - autoUpdater.removeListener("update-not-available", onNotAvailable); - autoUpdater.removeListener("error", onError); - }; - const onAvailable = () => { - cleanup(); - resolve(this.#latestInfo ?? {}); - }; - const onNotAvailable = () => { - cleanup(); - resolve(null); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - autoUpdater.once("update-available", onAvailable); - autoUpdater.once("update-not-available", onNotAvailable); - autoUpdater.once("error", onError); - try { - autoUpdater.checkForUpdates(); - } catch (error) { - cleanup(); - reject(error); - } - }).finally(() => { - this.#checkPromise = null; - }); - this.#checkPromise = promise; - return await promise; - } - - async #waitForDownloadedUpdate(): Promise { - if (this.#session.stage === "completed" && this.#latestInfo) { - return this.#latestInfo; - } - if (this.#downloadPromise) { - return await this.#downloadPromise; - } - - const promise = new Promise((resolve, reject) => { const cleanup = () => { autoUpdater.removeListener("update-downloaded", onDownloaded); autoUpdater.removeListener("update-not-available", onNotAvailable); @@ -525,28 +473,74 @@ export class ElectronUpdateHost { releaseDate, updateURL, ); - this.#latestInfo = info; + if (this.#assessUpdateInfo(info).status === "invalid") { + reject(new Error("更新清单未提供有效的候选版本")); + return; + } resolve(info); }; const onNotAvailable = () => { cleanup(); - reject(new Error("当前已是最新版本")); + resolve(null); }; const onError = (error: Error) => { cleanup(); reject(error); }; - autoUpdater.once("update-downloaded", onDownloaded); autoUpdater.once("update-not-available", onNotAvailable); autoUpdater.once("error", onError); + try { + autoUpdater.checkForUpdates(); + } catch (error) { + cleanup(); + reject(error); + } }).finally(() => { - this.#downloadPromise = null; + this.#checkPromise = null; }); - this.#downloadPromise = promise; + this.#checkPromise = promise; return await promise; } + #assessUpdateInfo(info: UpdateInfo | null): UpdateCandidateAssessment { + if (!info) { + return { status: "not_newer", version: null }; + } + return assessUpdateCandidate(info.version, app.getVersion()); + } + + #applyDownloadedUpdate(info: UpdateInfo): void { + const assessment = this.#assessUpdateInfo(info); + if (assessment.status === "invalid") { + this.#latestInfo = null; + this.#setSession({ + stage: "failed", + latestVersion: null, + downloadUrl: null, + percent: 0, + error: "更新清单未提供有效的候选版本", + message: "更新清单未提供有效的候选版本", + isActive: false, + completedAt: Date.now(), + }); + return; + } + + this.#latestInfo = info; + const hasUpdate = assessment.status === "newer"; + this.#setSession({ + stage: hasUpdate ? "completed" : "up_to_date", + latestVersion: assessment.version, + downloadUrl: hasUpdate ? this.#downloadUrl(info) : null, + percent: hasUpdate ? 1 : 0, + error: null, + message: hasUpdate ? "更新已下载,准备安装" : "当前已是最新版本", + isActive: false, + completedAt: Date.now(), + }); + } + #setSession(update: Partial): void { this.#session = { ...this.#session, @@ -657,7 +651,7 @@ function buildUpdateInfo( releaseName, releaseNotes: typeof releaseNotes === "string" ? releaseNotes : undefined, updateURL, - version: releaseName, + version: normalizeUpdateVersion(releaseName) ?? undefined, }; } diff --git a/electron/updateVersion.test.ts b/electron/updateVersion.test.ts new file mode 100644 index 000000000..c3f934229 --- /dev/null +++ b/electron/updateVersion.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { assessUpdateCandidate, normalizeUpdateVersion } from "./updateVersion"; + +describe("updateVersion", () => { + it.each([ + ["1.117.0", "1.117.0"], + ["v1.117.0", "1.117.0"], + ["1.116.0", "1.117.0"], + ])("候选版本 %s 相对当前版本 %s 不应视为更新", (candidate, current) => { + expect(assessUpdateCandidate(candidate, current)).toEqual({ + status: "not_newer", + version: candidate.replace(/^v/i, ""), + }); + }); + + it("只应把严格更高版本视为可安装更新", () => { + expect(assessUpdateCandidate("1.118.0", "1.117.0")).toEqual({ + status: "newer", + version: "1.118.0", + }); + }); + + it.each([undefined, "", "latest", "1.117", "1.117.0-01"])( + "无效候选版本 %s 应 fail closed", + (candidate) => { + expect(assessUpdateCandidate(candidate, "1.117.0")).toEqual({ + status: "invalid", + version: null, + }); + }, + ); + + it("应遵循 SemVer 预发布版本优先级并忽略构建元数据", () => { + expect(assessUpdateCandidate("1.118.0-rc.1", "1.118.0-beta.9").status).toBe( + "newer", + ); + expect(assessUpdateCandidate("1.118.0", "1.118.0-rc.1").status).toBe( + "newer", + ); + expect( + assessUpdateCandidate("1.118.0+build.2", "1.118.0+build.1").status, + ).toBe("not_newer"); + expect(normalizeUpdateVersion("V1.118.0-rc.1+build.2")).toBe( + "1.118.0-rc.1+build.2", + ); + }); +}); diff --git a/electron/updateVersion.ts b/electron/updateVersion.ts new file mode 100644 index 000000000..173e5eab7 --- /dev/null +++ b/electron/updateVersion.ts @@ -0,0 +1,127 @@ +interface ParsedUpdateVersion { + core: [string, string, string]; + normalized: string; + prerelease: string[]; +} + +export type UpdateCandidateStatus = "invalid" | "newer" | "not_newer"; + +export interface UpdateCandidateAssessment { + status: UpdateCandidateStatus; + version: string | null; +} + +const UPDATE_VERSION_PATTERN = + /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/i; + +function parseUpdateVersion( + value: string | null | undefined, +): ParsedUpdateVersion | null { + const match = UPDATE_VERSION_PATTERN.exec(value?.trim() ?? ""); + if (!match) { + return null; + } + + const prerelease = match[4]?.split(".") ?? []; + if ( + prerelease.some( + (identifier) => + /^\d+$/.test(identifier) && + identifier.length > 1 && + identifier.startsWith("0"), + ) + ) { + return null; + } + + const core: [string, string, string] = [match[1], match[2], match[3]]; + const prereleaseSuffix = + prerelease.length > 0 ? `-${prerelease.join(".")}` : ""; + const buildSuffix = match[5] ? `+${match[5]}` : ""; + return { + core, + normalized: `${core.join(".")}${prereleaseSuffix}${buildSuffix}`, + prerelease, + }; +} + +function compareNumericIdentifiers(left: string, right: string): number { + if (left.length !== right.length) { + return left.length > right.length ? 1 : -1; + } + if (left === right) { + return 0; + } + return left > right ? 1 : -1; +} + +function comparePrerelease(left: string[], right: string[]): number { + if (left.length === 0 || right.length === 0) { + if (left.length === right.length) { + return 0; + } + return left.length === 0 ? 1 : -1; + } + + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const leftIdentifier = left[index]; + const rightIdentifier = right[index]; + if (leftIdentifier === undefined || rightIdentifier === undefined) { + return leftIdentifier === undefined ? -1 : 1; + } + if (leftIdentifier === rightIdentifier) { + continue; + } + + const leftNumeric = /^\d+$/.test(leftIdentifier); + const rightNumeric = /^\d+$/.test(rightIdentifier); + if (leftNumeric && rightNumeric) { + return compareNumericIdentifiers(leftIdentifier, rightIdentifier); + } + if (leftNumeric !== rightNumeric) { + return leftNumeric ? -1 : 1; + } + return leftIdentifier > rightIdentifier ? 1 : -1; + } + return 0; +} + +function compareParsedVersions( + left: ParsedUpdateVersion, + right: ParsedUpdateVersion, +): number { + for (let index = 0; index < left.core.length; index += 1) { + const result = compareNumericIdentifiers( + left.core[index], + right.core[index], + ); + if (result !== 0) { + return result; + } + } + return comparePrerelease(left.prerelease, right.prerelease); +} + +export function normalizeUpdateVersion( + value: string | null | undefined, +): string | null { + return parseUpdateVersion(value)?.normalized ?? null; +} + +export function assessUpdateCandidate( + candidateVersion: string | null | undefined, + currentVersion: string | null | undefined, +): UpdateCandidateAssessment { + const candidate = parseUpdateVersion(candidateVersion); + const current = parseUpdateVersion(currentVersion); + if (!candidate || !current) { + return { status: "invalid", version: null }; + } + + return { + status: + compareParsedVersions(candidate, current) > 0 ? "newer" : "not_newer", + version: candidate.normalized, + }; +} diff --git a/internal/aiprompts/architecture.md b/internal/aiprompts/architecture.md index d66667626..29935fc46 100644 --- a/internal/aiprompts/architecture.md +++ b/internal/aiprompts/architecture.md @@ -1699,3 +1699,39 @@ Architecture impact: major because a live notification and recovery surface move to the v2 protocol/projector/Renderer boundary. The product direction remains Electron Desktop Host -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection -> GUI. Architecture diagram updated: this section and the App Server notification boundary above. Responsible developer confirmation: root, 2026-07-31. + +## 28. Skills Changed Catalog Invalidation Owner + +Skill catalog invalidation uses one current catalog owner and one typed transient notification path: + +```text +default Skill roots create / modify / remove + or successful Lime Skill catalog mutation + -> invalidate lime-skills executable snapshot cache + -> App Server v2 skills/changed {} + -> app_server_handle_json_lines notification drain + -> Renderer typed notification bus + -> current skill/list + -> Composer Skill catalog projection and GUI refresh +``` + +The App Server watches only default Skill roots. Existing roots are recursive watches; roots created after startup are +reconciled and watched. Filesystem create, modify and remove events invalidate the executable snapshot and broadcast at +most once per ten-second throttle window. Successful Lime catalog mutations invalidate the same cache and attach the +same typed notification at the processor boundary; failed mutations and mutations for other apps do not notify. + +`skill/list` is the sole executable catalog read for Composer. The Renderer API reuses the existing typed executable +Skill decoder and applies a pure GUI projection. `skillManagement/list` retains its management-center semantics and does +not become a second Composer catalog owner. Reconnect or remount performs a fresh list independently of notification +delivery. `skills/changed` carries the strict empty object only and is a process-level transient invalidation; it does not +create a Thread/Turn/Item, durable event, persistence record or replay requirement. + +The v2 protocol/schema/generated client, App Server watcher and successful mutation producer, `lime-skills` cache +invalidation, Renderer typed event bus and current `skill/list` refresh are `current`. No `compat` path exists. +`skills/extraRoots/set`, a second catalog/read model, durable notification replay and production mock fallback were not +added and are `dead / forbidden-to-restore` as alternate implementations. + +Architecture impact: major because this adds a public notification producer and GUI invalidation boundary while keeping +the product direction unchanged: Electron Desktop Host -> App Server JSON-RPC -> RuntimeCore/domain owner -> GUI. +Architecture diagram updated: this section and the Skill catalog path above. Responsible developer confirmation: root, +2026-07-31. diff --git a/internal/aiprompts/commands.md b/internal/aiprompts/commands.md index ea60de882..ddd79b289 100644 --- a/internal/aiprompts/commands.md +++ b/internal/aiprompts/commands.md @@ -18,13 +18,13 @@ Renderer typed gateway ## Owner 判定 -| 需求 | Owner | -| --- | --- | -| Thread / Turn / Item、read model、evidence、业务查询与写入 | App Server protocol + handler + current Rust domain | -| 模型路由、canonical content、capability、provider wire lowering | `runtime-core` / `model-provider` | -| 工具定义、审批、sandbox、dispatch、MCP | `tool-runtime` | -| 窗口、系统文件选择、通知、Dock、tray、updater、sidecar | Electron Desktop Host | -| UI request builder、response normalization、projection | Renderer `src/lib/api/` 或 typed package | +| 需求 | Owner | +| --------------------------------------------------------------- | --------------------------------------------------- | +| Thread / Turn / Item、read model、evidence、业务查询与写入 | App Server protocol + handler + current Rust domain | +| 模型路由、canonical content、capability、provider wire lowering | `runtime-core` / `model-provider` | +| 工具定义、审批、sandbox、dispatch、MCP | `tool-runtime` | +| 窗口、系统文件选择、通知、Dock、tray、updater、sidecar | Electron Desktop Host | +| UI request builder、response normalization、projection | Renderer `src/lib/api/` 或 typed package | 禁止为业务调用新增第二个 Electron 后端、renderer mock fallback、临时 DevBridge 命令或 legacy wrapper。生产失败必须显式失败;mock 仅在测试夹具中显式注入。 @@ -43,13 +43,13 @@ Renderer typed gateway ## 验证入口 -| 风险 | 最低验证 | -| --- | --- | -| Typed client / protocol | `npm run test:contracts` | -| Rust domain | `npm run test:rust:related -- ` | -| Desktop bridge | `npm run test:contracts` + `npm run verify:gui-smoke` | -| Agent 主链 | `npm run smoke:agent-runtime-current-fixture` | -| 真实桌面闭环 | Gate B Electron fixture / GUI smoke | +| 风险 | 最低验证 | +| ----------------------- | ----------------------------------------------------- | +| Typed client / protocol | `npm run test:contracts` | +| Rust domain | `npm run test:rust:related -- ` | +| Desktop bridge | `npm run test:contracts` + `npm run verify:gui-smoke` | +| Agent 主链 | `npm run smoke:agent-runtime-current-fixture` | +| 真实桌面闭环 | Gate B Electron fixture / GUI smoke | 具体质量选择见 [quality-workflow.md](quality-workflow.md)。 @@ -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/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`。事件 `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/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。 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 名、范围和工具/资源摘要。 diff --git a/internal/aiprompts/mcp.md b/internal/aiprompts/mcp.md index c9b05fc3e..d1336b49d 100644 --- a/internal/aiprompts/mcp.md +++ b/internal/aiprompts/mcp.md @@ -20,7 +20,7 @@ src/lib/api/ src/hooks/ ├── useMcp.ts # MCP runtime 状态与事件刷新 -└── useMcpEvents.ts # Desktop Host MCP event bridge +└── useMcpEvents.ts # MCP Desktop events 与 App Server typed notification bridge src/components/mcp/ ├── McpPage.tsx # 设置页入口 @@ -54,6 +54,7 @@ Current MCP method 固定为: - `mcpServer/importFromApp` - `mcpServer/syncAllToLive` - `mcpServer/oauth/login` +- `mcpServer/oauthLogin/completed` - `mcpServer/start` - `mcpServer/stop` - `mcpTool/list` @@ -80,7 +81,7 @@ Current MCP method 固定为: - `stdio` 与 `streamable_http` 都由 `lime-rs/crates/mcp` 管理。 - HTTP header 只允许来自配置中的安全字段或环境变量引用;inline secret、非法 header、缺失 env var 必须 fail closed。 -- OAuth 登录走 `mcpServer/oauth/login`,使用系统浏览器 current 网关打开授权 URL,callback 完成后通过 `mcp:oauth_completed` 刷新前端。 +- OAuth 登录走 `mcpServer/oauth/login`,使用系统浏览器 current 网关打开授权 URL;callback 成功或失败后由 App Server 发布 `mcpServer/oauthLogin/completed` typed notification,Renderer 经 typed event bus 刷新 server/tool 状态。旧 `mcp:oauth_completed` Desktop event 为 `dead / deleted / forbidden-to-restore`。 - OAuth token store 使用 app data runtime 下的 versioned credential envelope,按 server name + URL 隔离。 - 显式 `oauth.client_id` / `oauth_resource` 目前仍按 unsupported fail-closed,不能伪装成可用。 @@ -119,6 +120,7 @@ MCP smoke: 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-current -- --allow-live-provider ``` diff --git a/internal/exec-plans/README.md b/internal/exec-plans/README.md index 51baec6c3..8cdf67ef7 100644 --- a/internal/exec-plans/README.md +++ b/internal/exec-plans/README.md @@ -51,6 +51,8 @@ - 旧 Tauri wrapper 清理入口已合并到生产命令 current 迁移计划;历史 quick cleanup 队列和机械 inventory 文件已删除,不再作为 current 执行计划入口。 - Provider 模型能力 taxonomy 进度日志:`internal/exec-plans/provider-model-taxonomy-progress.md` - Windows Lime Hub 模型路由修复:`internal/exec-plans/windows-runtime-model-route-repair-plan.md` +- Windows 模型目录与更新可靠性闭环:`internal/exec-plans/windows-model-catalog-and-updater-reliability.md` +- Lime 核心用户主流程 E2E 审计:`internal/exec-plans/core-user-flow-e2e-audit-2026-07-31.md` - MCP 现代化进度:`internal/exec-plans/mcp-modernization-progress.md` - Right Surface 统一承载实施进度:`internal/exec-plans/right-surface-implementation-progress.md` - Soul Style 输出面收敛计划:`internal/exec-plans/soul-style-output-surface-convergence-plan.md` diff --git a/internal/exec-plans/core-user-flow-e2e-audit-2026-07-31.md b/internal/exec-plans/core-user-flow-e2e-audit-2026-07-31.md new file mode 100644 index 000000000..62fd3d939 --- /dev/null +++ b/internal/exec-plans/core-user-flow-e2e-audit-2026-07-31.md @@ -0,0 +1,193 @@ +# Lime 核心用户主流程 E2E 审计 + +状态:本轮范围完成;Windows packaged updater 待实机验收 +日期:2026-07-31 + +## 目标与用户闭环 + +普通桌面用户从启动 Lime 开始,完成 Provider / 模型确认,创建对话并完成一轮响应,再验证停止后继续、历史恢复、关键设置与更新状态。完成标准不是页面可打开,而是 GUI 状态、Electron IPC、App Server current JSON-RPC 与 read model identity 一致,生产 mock fallback 命中为零。 + +## 范围 + +- 启动、首页加载、主导航、控制台基线。 +- `设置 -> AI 服务商` 的 Provider 列表、显式模型可见性与返回聊天后的模型选择。 +- Claw 新会话、用户消息、流式 assistant 输出与真实 terminal。 +- 停止、输入框立即恢复、同一会话继续下一轮。 +- 最近对话 / 历史详情恢复与 Electron 重启后的 read model。 +- `设置 -> 关于` 的当前版本与更新状态语义。 + +不做范围:不修改真实用户 Provider 凭证;不保存配置、API key、完整 prompt 或 provider 响应;不把 external fixture 当作 live Provider;不宣称 macOS Gate B 能替代 Windows Squirrel packaged 验收;不覆盖社媒内容、素材、浏览器自动化、MCP、Agent Apps 和所有 `@` 命令的产品矩阵。 + +## 写集 + +- 本执行记录。 +- `src/components/api-key-provider/ModelAddPanel.tsx`。 +- `src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx`。 +- `src/RootRouter.tsx`、`src/RootRouter.test.tsx`。 +- `src/components/ui/sonner.test.tsx`。 +- `src/components/agent/chat/utils/agentRuntimeErrorPresentation.ts` 及其测试。 +- `src/components/agent/chat/hooks/agentStreamSubmitFailure.ts`。 +- `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 及其测试。 +- `src/i18n/resources/{zh-CN,zh-TW,en-US,ja-JP,ko-KR}/settings.json`。 +- 发现其它缺陷后,只扩展到最接近根因的 current owner、定向测试与五语言资源,并在修改前登记。 + +避让:`internal/aiprompts/architecture.md`、`internal/exec-plans/refactor-v2-implementation.md`、App Server protocol / Skills runtime 并行脏区,以及当前未归属本任务的工作树改动。 + +## Agent Verification Contract + +### 基本信息 + +```text +改动名称:Lime 核心用户主流程 E2E 审计 +执行计划文件:internal/exec-plans/core-user-flow-e2e-audit-2026-07-31.md +负责人:Codex +预算标签:budget:normal +风险等级:P0 +影响模块:Electron Desktop Host、App Server current session、Claw GUI、Provider / model GUI、历史恢复、Updater GUI +不做范围:live Provider、真实用户配置写入、Windows packaged installer +``` + +### Current 主链 + +```text +前端入口:Claw 首页、模型选择器、设置页、最近对话 +前端网关:src/lib/api/* typed gateway / AppServerClient +Electron Desktop Host bridge:preload contextBridge -> electron-ipc -> app_server_handle_json_lines +App Server method:agentSession/* current session surface 及对应 read/list;宿主更新能力不进入业务 JSON-RPC +RuntimeCore / service owner:RuntimeCore external fixture backend;Updater 归 electron/updateHost.ts +read model:Thread / Turn / Item projection 与 current session read model +runtime event:message.delta、turn.completed、turn.canceled 及 current terminal projection +Evidence Pack 字段:method、transport、status、sessionId、turnId、scenario marker、mock/console error 计数 +GUI surface:Claw 输入框、消息列表、停止按钮、最近对话、AI 服务商、关于 +``` + +### Happy Path + +```text +用户输入 / Agent 输入:隔离 fixture 中发送无敏感信息的场景 marker +预期 runtime events:turn started -> message delta -> turn completed;取消场景为 turn canceled +预期 tool calls:基础聊天无需工具;不以工具调用作为完成条件 +预期 approval / sandbox:无审批;fixture 不写用户工作区 +预期 artifact:无 +预期 evidence:Electron/preload/IPC/App Server/runtime/read model/GUI identity 一致 +预期 GUI 状态:消息可见、terminal 后输入框恢复、历史可重新打开、Provider 显式模型可见、更新状态不误报 +失败时应停在哪一层:按 Renderer、Electron bridge、App Server、runtime/read model、平台 updater 精确分类 +``` + +### Evidence Layers + +| Layer | 本次是否需要 | 证据路径 / 计划路径 | 不需要的原因 | +| --- | --- | --- | --- | +| deterministic-smoke | 是 | `smoke:agent-runtime-current-fixture` 与 Claw fixture summaries | - | +| gui-trace | 是 | 真实 Electron fixture / CDP,记录脱敏 trace 摘要 | - | +| runtime-transcript | 是 | fixture summary 中的 terminal 与 read model identity | - | +| release-artifact | 否 | - | 本轮不制作或安装发行包 | + +### 必跑命令 + +```bash +# C0 +npm run test:contracts + +# C1 +npm run smoke:agent-runtime-current-fixture +npm run smoke:claw-chat-current-fixture +npm run smoke:claw-chat-current-fixture -- --scenario cancel +npm run smoke:claw-chat-current-fixture -- --scenario cancel-then-continue +npm run smoke:agent-session-history-electron-fixture + +# C2 +npm run verify:gui-smoke +LIME_ELECTRON_REMOTE_DEBUGGING_PORT=9223 npm run electron:dev +npm run bridge:health -- --timeout-ms 120000 +``` + +未跑:C3 / C4 qcloop、live Provider、Windows packaged updater。 +原因:用户未要求发送数据到真实 Provider,本机不是 Windows packaged 环境。 +风险:不能证明具体线上 Provider 的回答质量、网络稳定性或 Windows 安装器闭环。 +后续触发条件:明确授权 live Provider,或提供 Windows N-1 packaged 测试机。 + +### Agent QC 场景映射 + +```text +P0: 启动与主链可用、消息可见、真实 terminal、取消、取消后继续、历史恢复 +P1: Provider / 模型可见性、设置导航、更新状态语义 +P2: 非主流程页面视觉与扩展能力 +``` + +选择依据:本轮验证用户最基本的可用闭环;允许 deterministic sidecar / external fixture,但 official evidence 只声明到对应 Gate,不冒充 live Provider 或 packaged Windows。 + +### Supervisor Rubric + +本轮不使用 LLM Supervisor。确定性断言只判断:主流程能否完成、identity 是否一致、mock / console / invoke error 是否为零。 + +### 回写规则 + +```text +失败类型:产品阻塞、体验误导、信息泄露、桥接缺口、测试缺口、环境噪音 +回写资产:最接近根因的单元 / 组件测试、fixture smoke、Playwright trace 或 GUI flow +关闭条件:修复后同一场景复测通过,且不恢复 compat / mock fallback +``` + +### 完成标准 + +```text +主线目标是否完成:是;已完成 controlled current 主链与 macOS Electron Gate B,未扩张到 live Provider 或 Windows packaged claim +已跑验证:Provider / updater / typed error 定向回归、contracts、App Server protocol check、current runtime 聚合 fixture、typed error success/failure Gate B、GUI smoke、版本一致性 +未跑验证及原因:live Provider 与 Windows packaged,见上文 +是否存在 token / Provider / GUI owner 风险:不调用 live Provider;真实 Electron CDP 独占本轮窗口 +是否可进入 release evidence:否,缺 Windows packaged 与 live Provider claim +下一刀:在 Windows N-1 Squirrel 安装包上完成发现、下载、重启安装与最终版本确认 +``` + +### 架构图确认 + +```text +架构影响:当前为只读 E2E 审计,尚未改变 owner、协议、read model 或交付门禁 +架构图已更新:不适用;若修复触达架构敏感路径,将重新分类 +责任开发者确认:不适用;当前无重大架构变更 +``` + +## 验收矩阵 + +| 场景 | Proof level | 状态 | 证据 / 问题 | +| --- | --- | --- | --- | +| 启动、首页与导航 | Gate B | 通过 | `verify:gui-smoke` 21/21;首页长短输入 current Electron fixture 通过 | +| Provider 与显式模型 | Gate A + Gate B bridge | 通过,Windows 平台待补 | Provider / ModelSelector 68/68;Provider hooks / storage / context / integration 26/26;不声明 live Provider | +| 新会话、stream、terminal | Current fixture + Gate B | 通过 | 聚合 current fixture 通过;typed error success/failure 均为 Gate B controlled fixture | +| 停止后继续 | Current fixture + Gate B | 通过 | cancel、cancel-then-continue 与 active steer 聚合场景通过 | +| 历史恢复与重启 | Gate B fixture | 通过 | history/cache hydration、Plan history hydrate 与 Coding Workbench 恢复通过 | +| 关于与更新状态 | Gate A + host unit | 通过,Windows packaged 待补 | updater / About / UpdateNotification / Sidebar 51/51;版本 `1.117.0`;Squirrel N-1 实机未验收 | + +## 问题台账 + +| 严重度 | 分类 | 场景 | 状态 | 根因 / 修复 | +| --- | --- | --- | --- | --- | +| P0 | 桥接缺口 / 构建阻塞 | 任何需要 source 重建 App Server sidecar 的 Electron 主流程 | 已解决 | typed `error` notification 的 Rust 关联项歧义已消除;`cargo check -p app-server-protocol`、host 重建与 GUI smoke 通过 | +| P0 | 桥接缺口 / contract 漂移 | Electron host fixture 重建 | 已解决 | generated TypeScript notification union、client 与 fixture 已同步;`test:contracts` 和聚合 Electron fixture 通过 | +| P0 | 产品阻塞 / 体验误导 | 自定义 Provider 添加 | 已修复 | 未保存态改为“取消”;保存成功但连接测试失败时,“完成添加”仍激活已持久化 Provider | +| P0 | 产品阻塞 | 发送失败反馈 | 已修复并回归 | 恢复全局 Toaster;typed error success/failure Electron fixture 均无 console / invoke error | +| P0 | 信息泄露 / 体验误导 | 不可执行模型路由发送 | 已修复并回归 | 用户只看到五语言 Provider 不可用提示,内部路由错误保留在诊断边界 | +| P0 | 测试缺口 | typed retry GUI 等待 | 已修复 | fixture 原先只在用户消息分组内查 assistant 状态且错误要求输入框禁用;改用全局 `data-status` 语义状态并验证 active steer 输入可用 | +| P0 | 测试缺口 | typed retry trace 聚合 | 已修复 | external backend 在 retry 后漏发 `provider.first_text_delta.received`;补齐 Provider checkpoint,不放宽 Provider / App Server 分离断言 | +| P0 | 测试缺口 | typed retry read model | 已修复 | 删除过时的 `detail.thread_read` 私有状态读取,统一使用 canonical `turns[]` owner;completed 等待同时要求真实 terminal | +| P0 | 测试缺口 | typed retry failure 中间态 | 已修复 | 不再等待不会进入 `thread/read` 的内部错误 detail;改为证明 prompt 保留且 read model 在 `turn.failed` 前仍为非终态 | +| P1 | 可维护性 | Electron fixture 编排文件超过 1000 行 | 已登记退出条件 | `backend-script.mjs`、`smoke.mjs` 与 `smoke.test.mjs` 不再继续承接下一种 backend 场景;新增场景前先把 typed error backend 生成片段和 assertion guard 拆到独立领域模块,聚合入口只保留编排 | + +## 执行日志 + +- 2026-07-31:建立 `budget:normal / P0` 审计合同;本机 `1420 / 3030 / 9223` 均无监听,从干净环境开始。 +- 2026-07-31:`npm run smoke:agent-runtime-current-fixture` 通过;覆盖首页首发、stream terminal、停止后继续、历史 hydrate、审批、计划、Skills / MCP、媒体与工作台,明确 `liveProviderUsed=false`。 +- 2026-07-31:随后单独运行 `npm run smoke:claw-chat-current-fixture` 时,检测到 13:14 更新的并行 App Server protocol source 比 13:03 的 sidecar 新,重建在 `envelopes.rs:753` 失败;错误为 `ambiguous associated item`。旧聚合证据有效但不能替代当前 source build,新的 Gate B 暂停。 +- 2026-07-31:测试进程临时设置 `RUSTFLAGS=-Aambiguous-associated-items` 后,`cargo check -p app-server-protocol` 通过,但继续重建 host 时 `packages/app-server-client` 因 `error` notification 未进入生成 method union 而失败。临时 lint 参数只用于继续诊断,不关闭默认构建阻塞。 +- 2026-07-31:隔离 Electron CDP 复现 Provider 静默丢失:填写 `E2E Local Provider / e2e-explicit-model` 后点击“完成添加”,页面回到“还没有启用模型”,console / invoke error 均为 0;源码确认该按钮直接绑定 `onCancel`。 +- 2026-07-31:继续复现保存失败分支:本机不可达 endpoint 的配置成功持久化,设置列表显示 `e2e-explicit-model` 且标为默认,但返回首页仍只显示 `gpt-5.2-pro`;根因为完成动作未调用 `onActivated`,导致 persisted provider 与 active provider 分裂。 +- 2026-07-31:沿不可执行 Provider 真实发送:`agentSession/turn/start` 经 `electron-ipc -> app_server_handle_json_lines` 返回 `runtime model route is not executable`;输入恢复,但无可见 toast。源码确认生产唯一 Toaster 挂载在发布改动中被删除。 +- 2026-07-31:恢复 `RootRouter` 的 Toaster 挂载,覆盖主应用、Browser Runtime、Resource Manager 和开发 smoke 窗口;新增真实 Sonner DOM 回归。发送提交与流式提交失败统一使用 `resolveAgentRuntimeSubmitErrorMessage`,将不可执行路由隐藏为 Provider 暂不可用五语言文案。 +- 2026-07-31:定向 Vitest 通过:`RootRouter.test.tsx` 4/4、`sonner.test.tsx` 1/1、`agentRuntimeErrorPresentation.test.ts` 14/14、`useWorkspaceSendActions.test.tsx` 155/155。 +- 2026-07-31:修复 typed error fixture 的 DOM 作用域、`data-status`、Provider checkpoint、canonical read model 状态读取与 pre-terminal 等待合同;fixture guard 81/81 通过。 +- 2026-07-31:typed error / runtime status 最终定向回归 93/93 通过;全工作树 `git diff --check` 通过,fixture 进程无残留。 +- 2026-07-31:`typed-error-retry-success` 与 `typed-error-retry-failure` 真实 Electron controlled Gate B 均通过;两者均命中 Electron IPC、App Server current JSON-RPC、Provider/App Server trace 分离、同一 turn identity,console / invoke error 为零。 +- 2026-07-31:`npm run smoke:agent-runtime-current-fixture` 完整通过;覆盖首页、历史恢复、停止继续、审批、active steer、Plan、Skills、MCP、媒体、代码工作台、typed error success/failure 与内容工厂,`liveProviderUsed=false`。 +- 2026-07-31:`npm run verify:gui-smoke` 通过,21/21 assertions;evidence:`.lime/qc/project-gates/standalone-shell-01-20260731103342-78558/shell-01-electron-smoke/summary.json`。 +- 2026-07-31:本轮 controlled 主流程完成。Windows packaged Squirrel 从 N-1 到新版本的发现、下载、重启安装与最终版本确认仍是唯一发布平台阻塞,完成度 92%。 diff --git a/internal/exec-plans/mcp-modernization-progress.md b/internal/exec-plans/mcp-modernization-progress.md index baddba39f..cd9f7fd51 100644 --- a/internal/exec-plans/mcp-modernization-progress.md +++ b/internal/exec-plans/mcp-modernization-progress.md @@ -9,6 +9,7 @@ ## 分类 - `current`:`lime-rs/crates/mcp`、App Server `mcp*` JSON-RPC methods、前端 `src/lib/api/mcp.ts` +- `current`:OAuth completion 只由 App Server `mcpServer/oauthLogin/completed` typed notification 投影给 Renderer - `current`:runtime 工具命名 `mcp____`,extension surface key 继续使用 `mcp__` - `current`:`ToolSearchBridgeTool` 消费 MCP inventory / search 结果 - `test-only`:retired MCP facade 只允许停留在 contract forbidden snippet、smoke 观测黑名单与负向测试文本中 @@ -47,11 +48,11 @@ - App Server protocol schema、schema fixtures、npm client generated types、package client 测试与 MCP API fail-closed 测试已同步 `mcpServer/oauth/login`。 - MCP 管理面已消费 `runtime_status.auth_status.action_plan`:动态 OAuth 缺授权时显示登录入口,调用 `mcpServer/oauth/login` 并用系统浏览器打开 `authorizationUrl`;显式 `client_id/oauth_resource` unsupported 状态只显示不可用提示,不提供假登录入口;已授权状态显示为“已授权”。 - MCP OAuth 登录入口已补五语言 `settings` 文案与 `McpPanel` 组件回归,覆盖登录打开、unsupported fail-closed 和已授权状态。 -- OAuth callback 完成后会发出 `mcp:oauth_completed` 事件,前端 `useMcp` 监听事件后自动刷新 server/tool 列表,`McpPanel` 给出“授权已完成,状态已刷新”的五语言 toast。 -- 参考 Codex 的 `mcpServer/oauth/login -> async completion notification` 设计后,补齐本地回环 OAuth provider completion 回归:测试真实跑 rmcp metadata discovery、dynamic client registration、authorization redirect、callback、token exchange、持久 token store 与 `mcp:oauth_completed` 事件发射。 +- OAuth callback 完成后由 App Server 发出 `mcpServer/oauthLogin/completed` typed notification,前端 `useMcp` 经 typed event bus 自动刷新 server/tool 列表,`McpPanel` 给出“授权已完成,状态已刷新”的五语言 toast。 +- 参考 Codex 的 `mcpServer/oauth/login -> async completion notification` 设计后,补齐本地回环 OAuth provider completion 回归:测试真实跑 rmcp metadata discovery、dynamic client registration、authorization redirect、callback、token exchange、持久 token store 与 App Server typed notification 投影。 - 动态 OAuth 登录现在复用 streamable HTTP 的 `http_headers` / `env_http_headers` 构造路径;metadata discovery、dynamic client registration、持久凭据恢复和授权后的 MCP HTTP transport 使用同一组默认 headers,避免 OAuth 登录链路与普通 streamable HTTP 链路分叉。 - 动态 OAuth scopes 对齐 Codex 解析优先级:登录参数 scopes > server 配置 scopes > provider discovery `scopes_supported` > 空;discovery scopes 会去重、trim 并过滤空值,只在用户未显式传入且 server 未配置 scopes 时启用。 -- OAuth callback 支持标准 provider error 回调(如 `error=invalid_scope&error_description=...`),不再等到 5 分钟超时;后台立即通过既有 `mcp:server_error` 事件把失败投影给 GUI。 +- OAuth callback 支持标准 provider error 回调(如 `error=invalid_scope&error_description=...`),不再等到 5 分钟超时;App Server 立即通过 `mcpServer/oauthLogin/completed { success: false, error }` 把失败投影给 GUI。 - OAuth 回归测试已拆到 `lime-rs/crates/mcp/src/oauth_tests.rs`,`oauth.rs` 保持在代码体量边界内;`manager.rs` 巨型文件未继续追加新逻辑。 - MCP manager 已按职责拆分为 `manager/lifecycle.rs`、`manager/tools.rs`、`manager/prompts.rs`、`manager/resources.rs` 与 `manager/tests/*`;`manager.rs` 从 3000+ 行收敛到状态、连接池、缓存与事件 facade,当前最大子模块约 533 行,避免后续 transport / 工具 / prompt / resource 逻辑继续堆回中心文件。 - MCP GUI OAuth 登录打开授权页已从 `window.open` 收敛到 `openExternalUrlWithSystemBrowser` current 外链网关,避免 Electron 下绕过 Desktop Host 的浏览器打开链路;组件回归覆盖授权 URL 通过系统浏览器网关打开且不回退 `window.open`。 @@ -310,10 +311,10 @@ - `git diff --check -- "scripts/mcp/lib/current-smoke-core.mjs" "scripts/mcp/lib/current-smoke-transport.mjs" "scripts/mcp/current-smoke.test.mjs" "scripts/mcp/lib/contract-guards.mjs" "internal/exec-plans/mcp-modernization-progress.md"` - `rg -n "[ \t]+$" -- "scripts/mcp/lib/current-smoke-core.mjs" "scripts/mcp/lib/current-smoke-transport.mjs" "scripts/mcp/current-smoke.test.mjs" "scripts/mcp/lib/contract-guards.mjs" "internal/exec-plans/mcp-modernization-progress.md"`:无命中。 - `rg -n "^(<<<<<<<|=======|>>>>>>>)" -- "scripts/mcp/lib/current-smoke-core.mjs" "scripts/mcp/lib/current-smoke-transport.mjs" "scripts/mcp/current-smoke.test.mjs" "scripts/mcp/lib/contract-guards.mjs" "internal/exec-plans/mcp-modernization-progress.md"`:无命中。 -- MCP Desktop Host event bridge 监听已从 `src/hooks/useMcp.ts` 拆到 `src/hooks/useMcpEvents.ts`:事件 payload 类型、`safeListen` 注册、`mcp:server_started/stopped/error/tools_updated/resources_updated/resource_updated/oauth_completed` 处理集中在事件模块;`useMcp.ts` 只保留状态、action 与初始化编排,文件体量从约 553 行降到约 420 行。 -- `scripts/mcp/lib/contract-guards.mjs` 已纳入 `src/hooks/useMcpEvents.ts`,强制 MCP runtime events 继续通过 `safeListen` current bridge 注册,并禁止 `defaultMocks`、`mockPriorityCommands`、`invokeMockOnly`、`explicitMockFallback`、`listenExplicitMock` 回流到事件监听边界。 +- MCP event 监听已从 `src/hooks/useMcp.ts` 拆到 `src/hooks/useMcpEvents.ts`:`mcp:server_started/stopped/error/tools_updated/resources_updated/resource_updated` 继续通过 Desktop `safeListen`,OAuth completion 改为同步订阅 App Server typed event bus;`useMcp.ts` 只保留状态、action 与初始化编排。 +- `scripts/mcp/lib/contract-guards.mjs` 已纳入 `src/hooks/useMcpEvents.ts`,强制剩余 MCP Desktop runtime events 继续通过 `safeListen` current bridge、OAuth completion 只走 typed notification,并禁止 legacy OAuth event 与生产 mock fallback 回流。 - `npx eslint "src/hooks/useMcp.ts" "src/hooks/useMcpEvents.ts" "src/hooks/useMcp.test.tsx" --max-warnings 0` -- `npx vitest run "src/hooks/useMcp.test.tsx" --silent=passed-only --disableConsoleIntercept --testTimeout=30000`:6 passed,覆盖 OAuth completion、resources updated、resource updated 与订阅 current API 透传。 +- `npx vitest run "src/hooks/useMcp.test.tsx" --silent=passed-only --disableConsoleIntercept --testTimeout=30000`:9 passed,覆盖 typed OAuth success/failure、Desktop listener 阻塞时同步订阅、resources updated、resource updated 与订阅 current API 透传。 - `node --check "scripts/mcp/lib/contract-guards.mjs" && node "scripts/check-app-server-client-contract.mjs"`:通过,281 checks,MCP event bridge 守卫已纳入 App Server client contract guard。 - `npm run test:contracts`:通过,含 protocol types、App Server client contract、command contracts、mock priority commands=0、script governance、electron release workflow guard、harness cleanup 与 docs boundary。 - MCP 前端 API response guard 已从 `src/lib/api/mcp.ts` 拆到 `src/lib/api/mcpResponseGuards.ts`:`mcp.ts` 保留 `AppServerClient.request(...)`、App Server method 常量和 `mcpApi` 编排;response guard 模块集中校验 server list、lifecycle empty result、OAuth login、tool result、prompt result、resource content 与 resource templates,文件体量从约 512 行降到约 367 行。 @@ -410,13 +411,16 @@ - 2026-06-22:清理 `smoke:agent-runtime-tool-execution -- --batch runtime-introspection-tools` 的旧 ToolSearch 参数,移除 `caller / limit / include_deferred / include_schema`,只保留 current schema `query / max_results`;复跑该 smoke 通过,证明 ToolSearch 与 SendUserMessage 均完成。 - 已验证:`cargo fmt --all`;`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server runtime_backend_tool_inventory`(3 passed);`npm run smoke:agent-runtime-tool-execution -- --batch runtime-introspection-tools --output /tmp/lime-runtime-introspection-smoke.json --timeout-ms 180000`;`cargo test --manifest-path "lime-rs/Cargo.toml" -p lime-mcp streamable_http --lib`(13 passed);`npx vitest run "src/components/mcp/McpPage.test.tsx" --silent=passed-only --disableConsoleIntercept --testTimeout=30000`(4 passed);五语言 `settings.json` JSON parse;`npx eslint "src/components/mcp/mcpPageModel.ts" "src/components/mcp/McpPageEditor.tsx" "src/components/mcp/McpPage.test.tsx" --max-warnings 0`;`npm run smoke:mcp-current -- --allow-write-fixture --timeout-ms 120000`;Context7 `--allow-live-provider` + `env_http_headers` live smoke;`npx vitest run "scripts/mcp/current-smoke.test.mjs" --silent=passed-only --disableConsoleIntercept --testTimeout=30000`(6 passed);`npm run test:contracts`;`npm run verify:gui-smoke`;`git diff --check -- "lime-rs/crates/app-server/src/runtime_backend/tool_inventory.rs" "lime-rs/crates/app-server/src/runtime_backend/tests/tool_inventory.rs" "lime-rs/crates/mcp/src/streamable_http.rs" "scripts/agent-runtime/tool-execution-smoke.mjs" "src/components/mcp/mcpPageModel.ts" "src/components/mcp/McpPageEditor.tsx" "src/components/mcp/McpPage.test.tsx" "src/i18n/resources/zh-CN/settings.json" "src/i18n/resources/zh-TW/settings.json" "src/i18n/resources/en-US/settings.json" "src/i18n/resources/ja-JP/settings.json" "src/i18n/resources/ko-KR/settings.json"`。 +- 2026-07-31:OAuth completion 已从 `mcp:oauth_completed` Desktop event 迁到 App Server `mcpServer/oauthLogin/completed` typed notification。Rust MCP 只返回 completion handle;App 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。 + ## 当前缺口 -- OAuth、动态授权与用户 approval 已具备 GUI 登录入口、completion 自动刷新闭环、本地回环 OAuth provider completion 证据,以及 `smoke:mcp-current -- --allow-oauth-fixture` 的 Electron `open_external_url` 网关证据;下一刀应补真实第三方 provider 账号流的 live-gated / 手工 E2E 证据。 +- OAuth、动态授权与用户 approval 已具备 GUI 登录入口、typed completion 自动刷新闭环、本地回环 OAuth provider completion 证据、`smoke:mcp-current -- --allow-oauth-fixture` 的 Electron `open_external_url` 网关证据,以及 `smoke:mcp-oauth-notification-electron-fixture` 的专用真实 Electron Gate B;下一刀应补真实第三方 provider 账号流的 live-gated / 手工 E2E 证据。 - 显式 `oauth.client_id` / `oauth_resource` 仍按 unsupported fail-closed 处理,后续需要等 connector 能无损支持后再打开。 - rmcp 0.12 的 `exchange_code_for_token` 内部仍新建 reqwest client;Lime 目前能保证 OAuth discovery / dynamic registration / authorized MCP requests 复用自定义 headers,但不能无损保证 token endpoint 也接收自定义 headers。后续若要支持需要 upstream connector seam 或本地 OAuth flow 封装,不应靠假支持打开。 - rmcp 0.12 的 token exchange 内部自建 client 也意味着本地 loopback OAuth provider 在带系统代理的开发机上需要测试进程级 `NO_PROXY` 保护;当前保护只放在 Rust fixture 测试里,不修改生产进程环境。 -- MCP GUI 管理面仍缺真实第三方 OAuth provider 账号流 smoke;当前本地 OAuth fixture 已证明系统浏览器 current 网关、provider redirect、callback completion 与状态回流闭环。 +- MCP GUI 管理面仍缺真实第三方 OAuth provider 账号流 smoke;当前本地 OAuth fixture 已证明系统浏览器 current 网关、provider redirect、callback completion、App Server typed notification、自动 status/tool refresh 与 GUI 状态/toast 回流闭环。 - MCP `structuredContent` control-plane、runtime/read model/evidence projection、基础 Agent Chat 展示回归与真实 Electron fixture 可见证据已覆盖;下一刀若继续提升完整度,应补 Playwright 人工交互式证据或真实第三方 MCP provider 工具结果的 live-gated E2E。 - `smoke:mcp-current -- --allow-live-provider` 已对 Context7 远程 provider 跑通 `resolve-library-id` 与 `query-docs` 两条真实 tool E2E,且 `isError=true` 已 fail-closed;`smoke:mcp-config-electron-fixture` 已补真实 Electron 设置页创建 Context7 配置并经 `app_server_handle_json_lines -> mcpServer/list` 验证落库的 GUI 证据;`smoke:mcp-context7-live-electron-fixture` 已补真实 Electron 启动 / ToolSearch / tool call 远程 Context7 live 入口。剩余缺口是在用户本机显式设置真实 `CONTEXT7_API_KEY` 后复跑同一 live fixture,确认 header 注入语义;当前本机未设置时仍可验证 Context7 公共工具链路。 - Agent Runtime / Claw fixture 拆分后,current 聚合回归、WebTools rendering、Skills Runtime、Expert Skills Runtime 与 `cancel-then-continue` 已恢复到绿色;下一刀应继续补用户截图中更细的 Codex 桌面 UI 视觉一致性断言,例如未完成时不得折叠、搜索 / 思考 / final Markdown 的分段快照和完成前状态稳定性,而不是继续扩张单个 fixture 文件。 @@ -442,7 +446,7 @@ - `npm run verify:local` 本轮尝试已通过 `verify:app-version`、i18n resource check、i18n unused、全仓 eslint 与变更文件 i18n hardcoded scan;进入全量 `tsc --noEmit` 后因同一工作区已有多个并行 `verify:local` / `tsc --noEmit` 高 CPU 进程抢资源,长时间无输出,已主动终止本轮额外验证会话(退出码 130)。本轮 MCP 定向校验、contract 与 GUI smoke 已通过。 - 历史上 `npm run test:contracts` 曾被非 MCP `useAgentChat.ts` 脏改动阻塞;当前复跑已通过,MCP current smoke structuredContent 守卫已进入同一 contract 入口。 -- 本轮尝试 `npm run smoke:mcp-current -- --allow-oauth-fixture --timeout-ms 30000`,当前环境没有运行 `npm run electron:dev` / DevBridge,health check 直接 `fetch failed`;该 smoke 未能作为 GUI 证据使用,需在 Electron DevBridge 运行后复跑。 +- 历史尝试 `npm run smoke:mcp-current -- --allow-oauth-fixture --timeout-ms 30000` 时因未运行 `npm run electron:dev` / DevBridge 而 `fetch failed`,当时不能作为 GUI 证据;该阻塞现已由不依赖 DevBridge 的 `npm run smoke:mcp-oauth-notification-electron-fixture` 专用真实 Electron Gate B 关闭,旧失败只保留为历史记录。 - `scripts/mcp/current-smoke.mjs` 超 1000 行阻塞已收口,当前入口约 390 行,core 约 409 行,transport 约 340 行,fixture writer 约 157 行;后续继续扩 `smoke:mcp-current` 必须优先复用 `scripts/mcp/lib/current-smoke-core.mjs` / `current-smoke-transport.mjs` / `current-smoke-fixture.mjs` / `live-provider-smoke.mjs` 或继续按职责拆子模块,不得把 transport / fixture / assertion helper 长回入口。MCP runtime current contract 已从 `scripts/check-app-server-client-contract.mjs` 拆到 `scripts/mcp/lib/contract-guards.mjs`;根 contract 脚本当前约 8447 行,仍超过非生成代码体量边界,后续应继续按领域把非 MCP contract guard 拆出,不要把新领域规则继续追加到根脚本。 - Agent stream handler 超 1000 行阻塞已在 2026-06-22 收口:`agentStreamRuntimeHandler.ts` 当前约 986 行,后续不得再把 runtime lifecycle / terminal / timer 逻辑追加回中心 handler。剩余巨型 facade 是 `src/components/agent/chat/AgentChatWorkspace.tsx`,当前约 5589 行;下一刀如果继续治理 Agent Chat 工作区,应先按 session 列表 / task file runtime / right surface orchestration 分块拆分,并保持 `smoke:agent-runtime-current-fixture`、WebTools rendering fixture 与 `verify:gui-smoke` 绿色。 - 本轮曾并行复用 `/tmp/lime-target-mcp-resource-templates` 触发 cargo incremental / rmeta 写入竞争(`No such file or directory (os error 2)`);已改用独立 `CARGO_TARGET_DIR="/tmp/lime-target-appserver-mcp-resource-templates"` 复跑 App Server MCP fixture 并通过。 diff --git a/internal/exec-plans/refactor-v2-implementation.md b/internal/exec-plans/refactor-v2-implementation.md index 94f70f31c..7196c5214 100644 --- a/internal/exec-plans/refactor-v2-implementation.md +++ b/internal/exec-plans/refactor-v2-implementation.md @@ -1987,7 +1987,7 @@ OPEN_REF:继续按 EVENT-PROJECTIONS 推进其余 planned notification、Windo 验证:protocol v2 50/50、schema fixture 1/1、App Server warning 12/12、projection-summary recovery 1/1、Renderer 3 files / 61 tests、generated TypeScript drift check 通过。npm run test:contracts 通过,含 839 schema definitions、831 generated types、0 generation failure、289 app-server-client checks 及 command/harness/modality/scripts/release/docs guards。Rust related 覆盖 19 个 scoped packages、18 个 lib 目标,共 3597 项(3593 passed、4 个既有环境/live 测试 ignored、0 failed)。 -产品证据:npm run smoke:agent-runtime-current-fixture 完整通过且 liveProviderUsed=false;它证明 current Agent/Electron fixture 主链无回归,但没有普通 warning 专项 DOM 断言,因此不冒充 warning 专项 Gate B。通用 npm run verify:gui-smoke 通过 21/21 assertions,证据为 .lime/qc/project-gates/standalone-shell-01-20260730191439-55464/shell-01-electron-smoke/summary.json;该 Gate B-F 证明 Electron/preload/IPC/app_server_handle_json_lines/App Server/GUI shell 与零 mock fallback,不宣称实际命中 warning method。 +产品证据:npm run smoke:agent-runtime-current-fixture 完整通过且 liveProviderUsed=false;它证明 current Agent/Electron fixture 主链无回归,但没有普通 warning 专项 DOM 断言,因此不冒充 warning 专项 Gate B。fresh npm run verify:local 通过 i18n、lint、typecheck、112/112 Vitest、contracts、Rust changed-scope、Electron renderer/host build 与真实 GUI smoke;后者通过 21/21 assertions,证据为 .lime/qc/project-gates/standalone-shell-01-20260730194114-24572/shell-01-electron-smoke/summary.json。该 Gate B-F 证明 Electron/preload/IPC/app_server_handle_json_lines/App Server/GUI shell 与零 mock fallback,不宣称实际命中 warning method。 治理与格式:cargo fmt --manifest-path lime-rs/Cargo.toml --all --check、定向 Prettier、git diff --check 与 npm run governance:legacy-report 通过;治理扫描为零引用候选 0、分类漂移 0、边界违规 0。 @@ -1998,6 +1998,108 @@ OPEN_REF:继续按 EVENT-PROJECTIONS 推进其余 planned notification、Windo OPEN_REF:下一刀审计 skills/changed 的真实 Skill catalog producer、GUI refresh consumer 与 recovery 语义;只有形成 typed producer -> GUI refresh -> 风险匹配 Gate B 的垂直链才转 current。v2 总体完成度保守估算约 91%,仍非 release-ready。 ``` +### 9.72 V2-05 skills/changed catalog invalidation closure + +```text +状态:completed(2026-07-31);只关闭 `skills/changed` catalog invalidation 垂直切片,V2-05 与 v2 总体仍为 in-progress,不是 release-ready。 + +主目标:按 Codex `skills/changed` 的严格空对象、进程级瞬时失效语义,在唯一产品链内形成默认 Skill root / 成功本地 mutation -> App Server typed notification -> Renderer 重新读取 current Skill catalog -> GUI 可见更新;不进入 Thread/Turn/Item、持久化或恢复重放,不新增 `skills/extraRoots/set`、第二 catalog owner 或生产 mock fallback。 + +本轮窄写集:app-server-protocol v2 notification/method/schema/generated client;lime-skills snapshot cache invalidation;App Server Skill mutation producer/default-root watcher/public JSON-RPC tests;app-server-client typed decoder;Composer Skill hook、current catalog projection 与 focused tests;Skills runtime Gate B fixture;EVENT-PROJECTIONS、architecture、refactor v2 实施计划与本执行计划。共享工作树其它 provider、warning、guardian、realtime、Windows host capability 与 recovery 热区未做无关改写。 + +退出条件:`skills/changed` 只接受 `{}` 且进入 protocol catalog/schema/generated client;默认 root 在启动时存在、启动后首次创建、修改和删除时均可失效并按 10 秒节流;成功 Lime catalog mutation 发通知,失败及其他 app mutation 不发;Renderer 只消费 typed event bus 并重新调用 current `skill/list`,重连仍主动 list;Rust/TS/contracts/governance 通过;风险匹配 Gate B 证明真实 Electron/preload/IPC/App Server notification drain、重新 list、GUI catalog 更新与零 mock fallback。 + +实现事实:v2 protocol、schema、generated client 与 typed decoder 只接受严格空对象 `skills/changed {}`。App Server 启动默认 Skill root watcher;已存在 root 的创建/修改/删除和启动后首次出现均失效 `lime-skills` snapshot cache 并按 10 秒节流广播 typed notification。成功的 Lime catalog mutation 在 processor 边界失效同一 cache 并附带 notification;失败 mutation 和其他 app mutation 不发。没有新增 `skills/extraRoots/set`。 + +Renderer 事实:Composer `useLimeSkills` 的初始加载与 notification refresh 统一调用 current `skill/list`,`skillsApi.getRuntimeCatalog()` 复用 `skillExecutionApi.listExecutableSkills()` 的既有 decoder,再经纯 projection 映射 GUI shape。管理中心 `skillManagement/list` 保持原语义,不成为 Composer catalog owner。Renderer 只消费 typed event bus;命中后写 console-only `skillsChanged.received { method, count }` marker 并自动重读;重连/重新挂载仍主动 list。该事件是进程级瞬时失效,不进入 Thread/Turn/Item、持久化或恢复重放。 + +验证:纯 projection、API、Hook 与 fixture guard 共 4 files / 99 tests 通过;npm run typecheck、npm run test:contracts(840 schema definitions、832 generated types、0 generation failures、290 app-server-client checks)、npm run governance:legacy-report、Prettier、MJS node --check、cargo fmt --all --check 与 git diff --check 通过。npm run test:related 因仓库既有 runner 将 electron/ 目录当文件读取而报 EISDIR,未记为通过;本轮精确 Vitest 已覆盖相关前端回归。Rust related 因 Cargo.lock 自动扩大到 workspace lib;首次运行只暴露 protocol::v2::tests::typed_v2_envelope_schema_names_are_stable 的稳定清单漏项,补入 skills/changed 后 app-server-protocol 95/95 通过;workspace lib 最终汇总为 32 个 suite、4670 passed、5 ignored、0 failed。 + +产品证据:npm run smoke:claw-chat-current-fixture -- --scenario skills-runtime 通过,proof level 为 Gate B controlled fixture。真实 Electron/preload/IPC/app_server_handle_json_lines/App Server 链先完成初始 skill/list;隔离 HOME 默认 root 新建 notification-refresh/SKILL.md 后,typed skills/changed marker 为 1,skill/list 自动由 1 次增至 2 次且 transport=electron-ipc,GUI 新 Skill 可见,手动刷新点击为 0;console/page/actionable errors 与 failed assertions 均为 0。证据位于 .lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-summary.json。 + +整合复验:npm run smoke:agent-runtime-current-fixture 完整通过且 liveProviderUsed=false,包含 skills/changed、Inputbar rich restore、Skills Runtime 三入口及其余 current Agent/Electron fixture。首次聚合运行暴露的 Inputbar 失败属于 fixture DOM 定位合同:夹具用展示名 Capability Report 大小写敏感匹配 current catalog 的 capability-report,候选已可见但 target.click 从未执行;生产 CharacterMention 与 catalog identity 无缺陷。夹具现统一折叠大小写、空格、连字符和下划线,并由 source guard 锁定候选与 @capability-report badge 的同一等价规则。独立 inputbar-rich-restore Gate B 随后通过,text/image/path/skill 在 output-free cancel 后全部恢复,console/page error 与 mock fallback 为 0;证据为 .lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-inputbar-rich-restore-regression-summary.json。 + +通用 npm run verify:gui-smoke 通过,真实 Electron renderer、App Server sidecar、Claw shell reload 与 memory settings 均就绪;证据为 .lime/qc/project-gates/standalone-shell-01-20260731043417-63564/shell-01-electron-smoke/summary.json。fresh npm run verify:local 通过版本一致性和 i18n unused 后,在全仓 lint 被非本切片脏文件 src/components/input-kit/ModelSelector.tsx:432 的既有 react-hooks/exhaustive-deps warning 阻断,故未记为通过;本切片未改该文件。后续独立 npm run typecheck、contracts、99 项精确测试、GUI smoke、Rust fmt 与 Gate B 均明确通过。 + +架构确认:本切片把 Skill catalog mutation/filesystem invalidation、App Server typed notification 与 Renderer current list refresh 收敛到单一产品链,已同步 internal/aiprompts/architecture.md 第 28 节。责任开发者确认:root,2026-07-31。 + +治理分类:v2 skills/changed protocol/schema、App Server watcher/mutation producer、lime-skills cache invalidation、typed Renderer event bus 与 current skill/list GUI refresh 为 current;无 compat;skillManagement/list 仅保留管理中心读取语义;持久化/replay、skills/extraRoots/set、第二 catalog owner 与生产 mock fallback 均未新增。 + +OPEN_REF:下一刀回到 EVENT-PROJECTIONS.md,审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recovery;guardianWarning 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 92%,仍非 release-ready。 +``` + +### 9.73 V2-05 typed error retry/terminal closure + +```text +状态:completed(2026-07-31);只关闭 typed error retry/terminal 垂直切片,V2-05 与 v2 总体仍为 in-progress,不是 release-ready。 + +主目标:在唯一产品链内证明 error(willRetry=true) -> turn/completed(completed) 与 error(true)* -> error(false) -> turn/completed(failed);typed error 负责重试/错误可见性,权威 turn/completed 独占 Turn terminal,live、durable recovery、read model 和 GUI 使用同一 identity,生产 mock fallback 为零。 + +本轮窄写集:app-server-protocol v2 error notification/schema/generated client;App Server error projector 与 canonical read-model runtime notification merge;app-server-client typed decoder;agent-runtime-client signal/lifecycle 分流;Renderer strict typed error controller/presentation;Claw typed-error external fixture、assertion/evidence guards;EVENT-PROJECTIONS、refactor v2 实施计划与本执行计划。保留共享工作树其它 updater、provider、guardian、host capability 与 planned recovery 改动,不做无关改写。 + +退出条件:typed error 从 protocol/schema/generated client 到 Renderer strict decoder 一致;willRetry=true 不结束 Turn,willRetry=false 也必须等待权威 turn/completed;durable runtime.error 可由 canonical full/limited read model 恢复且不抢占后续 failed terminal;SDK signal 不污染 lifecycle sequence;定向 Rust/TS、contracts、Agent fixture 与 GUI smoke 通过;success/failure 两条 Gate B 证明真实 Electron/preload/IPC/app_server_handle_json_lines/App Server/RuntimeCore/read model/GUI、同一 identity 和零生产 mock fallback。 + +实现事实:App Server 把 durable runtime.error 投影成 v2 typed error;Renderer 只接受 typed event bus。willRetry=true 显示重试状态,后续 completed terminal 清理并完成;一个或多个 retry error 后的 willRetry=false 显示最终错误,仍由 failed terminal 决定 Turn。canonical read model 的 runtime notification merge 同时恢复 warning 与 error;runtime error 只形成可见错误证据,Turn 在权威 terminal 前保持 running。没有新增第二 persistence、第二 terminal reducer 或 raw agentSession/event wrapper。 + +SDK/fixture:agent-runtime-client 将 typed error 作为独立 signal,不送入 lifecycle sequence verifier。common assertion 将 provider first-text/provider-wait 与 App Server message delta、server timestamp、Renderer client output/paint、current trace method、W3C、GUI/read model/identity 分开;typed-error 场景只豁免不存在的 provider 首文本证据。新增回归锁定普通文本流仍要求 provider trace,typed-error 缺 App Server、Renderer 或 current trace 证据仍失败。 + +验证:typed-error Renderer/fixture 6 files / 94 tests、packages/app-server-client 8 files / 98 tests、packages/agent-runtime-client 22 tests 通过;Rust canonical runtime error recovery 与 runtime_error_does_not_preempt_a_later_turn_failed_terminal 两项定向回归分别通过。npm run test:contracts 通过,app-server-client contract 为 292 checks;npm run smoke:agent-runtime-current-fixture 完整通过且 liveProviderUsed=false;npm run verify:gui-smoke 21/21 assertions 通过,证据为 .lime/qc/project-gates/standalone-shell-01-20260731104641-30858/shell-01-electron-smoke/summary.json。 + +Gate B:success 场景观察 error(willRetry=true) -> turn/completed(completed),证据 .lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-typed-error-retry-success-gateb-02-summary.json;failure 场景观察 error(true)* -> error(false) -> pending read model -> turn/completed(failed) -> GUI/read-model/backend terminal,证据 .lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-typed-error-retry-failure-gateb-03-summary.json。两者 ok=true、proofLevel=Gate B controlled fixture;聚合回归 evidence 为同目录的 claw-chat-current-fixture-typed-error-retry-success-regression-summary.json 与 claw-chat-current-fixture-typed-error-retry-failure-regression-summary.json,均 ok=true。 + +架构确认:本切片没有改变 public owner、唯一产品链或依赖方向,只补齐既有 notification/projector/read-model/Renderer 边界的 typed contract 与恢复语义;无需改写 internal/aiprompts/architecture.md。责任开发者确认:root,2026-07-31。 + +治理分类:v2 typed error protocol/projector、durable runtime error recovery、strict Renderer signal 与权威 Turn terminal ownership 为 current;无 compat;raw error wrapper、provider trace 对 current 主链证据的错误替代和由 error 抢占 Turn terminal 的第二状态机为 dead/forbidden-to-restore;其余 V2-05 planned notification、host capability 与全面 recovery 仍为开放项。 + +OPEN_REF:下一刀回到 EVENT-PROJECTIONS.md,审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recovery;guardianWarning 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 93%,仍非 release-ready。 +``` + +### 9.74 V2-05 `turn/plan/updated` canonical checklist 与 cold recovery closure + +```text +状态:completed(2026-07-31);只关闭 `turn/plan/updated` canonical checklist 与恢复垂直切片,V2-05 与 v2 总体仍为 in-progress,不是 release-ready。 + +主目标:在唯一产品链内把 canonical `update_plan` 的结构化输出派生为 typed `turn.plan.updated`,让实时和 canonical cold read 都显示同一份执行 checklist;保留 canonical `update_plan` 工具项用于恢复,但不生成 Plan ThreadItem、不展示 Plan UI/decision panel/tool card,也不新增 Message synthesis 或 renderer mock fallback。 + +本轮窄写集:App Server canonical `ToolOutput.structured_content` 到 `turn.plan.updated` 的 producer;Renderer strict notification parser;`appServerCanonicalThreadProjection` 的 update_plan cold recovery 与定向测试;App Server client contract guard;EVENT-PROJECTIONS、v2 实施计划与本执行计划。保留共享工作树其它 provider、warning、skills/changed、typed error、host capability 与 planned recovery 热区,不做无关改写。 + +退出条件:实时通知只接受 typed `turn.plan.updated`;冷读从成功 `update_plan` 的最新有效快照恢复;真实 App Server wire `[ { name: "plan", value: "[...]" } ]` 可解析;空数组可清空旧 checklist,失败/非法快照不覆盖;read model 只保留 canonical `update_plan` tool item;真实 Electron Gate B 证明实时与 reload 可见、Plan Item/Plan UI/decision panel/tool card 为零、同一 identity 与零 invoke/console error。 + +实现事实:App Server 从 canonical `ToolOutput.structured_content` 派生 `turn.plan.updated`,Renderer parser 严格消费 `turn_plan_updated`;`canonicalTurnPlanItems` 扫描 completed/successful `update_plan` tool item,按最新有效快照投影 `{ content, status }`,同时支持对象参数与真实 wire 参数数组。Malformed JSON、重复/缺失 `plan` 参数、非允许 status、失败或非 completed tool 均 fail closed;没有 Plan ThreadItem 或第二 checklist owner。 + +验证:`appServerCanonicalThreadProjection.test.ts` 10/10;`npm run test:contracts` 通过,app-server-client contract 为 292 checks;`npm run typecheck`、`npm run smoke:agent-runtime-current-fixture` 与 `npm run verify:gui-smoke` 通过。Gate B 证据 `.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-summary.json` 的 `scenario=turn-plan-update`、`proofLevel=Gate B controlled fixture`、`ok=true`:`turn.plan.updated` `methodCount=1`,事件 drain `messageCount=28`、`planIndex=16`、`terminalIndex=27`;实时和 reload checklist 各有 2 项,`updatePlanToolCount=1`、`planItemCount=0`,`visiblePlanBlockCount=0`、`visiblePlanDecisionCount=0`、`visibleUpdatePlanToolRowCount=0`,Electron/preload/IPC/App Server/read model/GUI identity 一致,invoke/page/console error 均为 0。 + +治理分类:typed `turn.plan.updated` producer/parser、canonical `update_plan` recovery 与 run-control checklist 为 current;无 compat;`update_plan` 场景下的 Plan ThreadItem、Message/tool card checklist synthesis、Plan UI/decision panel/tool card 和 renderer mock fallback 为 dead/deleted/forbidden-to-restore;独立 Plan mode 的现役 UI 不在本切片范围内。 + +架构确认:本切片没有改变 public owner、唯一产品链或依赖方向,无需改写 `internal/aiprompts/architecture.md`。责任开发者确认:root,2026-07-31。 + +OPEN_REF:下一刀回到 EVENT-PROJECTIONS.md,审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recovery;guardianWarning 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 94%,仍非 release-ready。 +``` + +### 9.75 V2-05 MCP OAuth typed completion notification closure + +```text +状态:completed(2026-07-31);只关闭 mcpServer/oauthLogin/completed 垂直切片,V2-05 与 v2 总体仍为 in-progress,不是 release-ready。 + +主目标:把 MCP OAuth callback completion 从旧 Desktop event 收敛到 App Server v2 typed notification,使成功/失败、Renderer 自动刷新与 GUI 可见状态走唯一产品链;不保留 mcp:oauth_completed compat、不新增第二 OAuth 状态 owner或生产 mock fallback。 + +本轮窄写集:lime-mcp OAuth completion handle;App Server MCP notification producer;app-server-protocol v2 notification/schema/generated client;app-server-client strict decoder;Renderer useMcp typed event subscription 与回归;MCP OAuth 专用 Electron fixture;contract guard;MCP/V2 文档与本执行计划。保留共享工作树其他 V2-05 notification、host capability、recovery 与版本改动,不做无关重写。 + +退出条件:callback success/failure 都产生 mcpServer/oauthLogin/completed;name 非空、threadId 必填可空、success 必填、error 可选可空且 decoder fail closed;Renderer 在任何 await 前同步订阅 typed event bus,完成后自动刷新 server/tool;生产路径无 mcp:oauth_completed;Rust/TS/contracts/governance/scripts 通过;真实 Electron Gate B 证明 preload/IPC、app_server_handle_json_lines、App Server event drain、OAuth callback、GUI 授权状态/toast 与零 mock fallback。 + +实现事实:lime-mcp 返回 McpOAuthLoginHandle completion future,不再发 legacy OAuth event。App Server 等待 handle 后把 success/error 投影为 ServerNotification::McpServerOauthLoginCompleted;protocol、schema、generated TypeScript 与 app-server-client strict decoder 使用同一 payload。Renderer setupMcpEventListeners 在串行 Desktop safeListen 前同步注册 App Server notification,避免快速 callback 竞态;成功/失败都刷新 mcpServerStatus/list 与 mcpTool/list,成功显示 completion toast,失败显示 typed error。 + +验证:lime-mcp OAuth 22 passed、app-server-protocol v2 59 passed、App Server MCP processor 4 passed、app-server-client 101 passed、useMcp 9 passed。npm run test:contracts 通过,包含 847 schema definitions、839 generated protocol types、0 generation failures、292 app-server-client checks,以及 command/scripts/docs/legacy guards。 + +Gate B:npm run smoke:mcp-oauth-notification-electron-fixture 通过,proofLevel=Gate B、ok=true。真实 Electron/preload/IPC、app_server_handle_json_lines、App Server OAuth login/event drain、本地 provider redirect/callback/token exchange 与 Renderer GUI 全部命中;oauthRequiredVisible=true、oauthAuthorizedVisible=true、completionToastVisible=true,authorizeRequestCount=1、tokenRequestCount=1、scope=fixture.read,appServerHandleJsonLinesHitCount=3、appServerDrainEventsHitCount=4、openExternalUrlHitCount=1、electronIpcHitCount=8、mockFallbackHitCount=0、failedInvokeCount=0,console/page/invoke errors 为 0。自动刷新 methods 为 mcpServer/oauth/login、mcpServerStatus/list、mcpTool/list。证据:.lime/qc/mcp-oauth-notification/mcp-oauth-notification-fixture-summary.json 与同目录 PNG。 + +架构确认:本切片没有改变 public owner、唯一产品链或依赖方向,只把既有 OAuth completion 从 legacy Desktop event 迁到 App Server v2 typed notification,并补齐 Renderer/Gate B;无需改写 internal/aiprompts/architecture.md。责任开发者确认:root,2026-07-31。 + +治理分类:typed protocol/schema、App Server completion producer、strict client decoder、Renderer typed event bus、自动刷新与专用 Gate B 为 current;无 compat;mcp:oauth_completed 为 dead/deleted/forbidden-to-restore。 + +OPEN_REF:下一刀回到 EVENT-PROJECTIONS.md,继续审计具备真实 producer/consumer 的 planned notification、host capability 或 recovery;guardianWarning 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 95%,仍非 release-ready。 +``` + ## 10. 完成定义 v2 实施不能因为“几个进程都有改动”而标记完成。每个 slice 必须同时具备: diff --git a/internal/exec-plans/release-v1.118.0-plan.md b/internal/exec-plans/release-v1.118.0-plan.md new file mode 100644 index 000000000..1032ff795 --- /dev/null +++ b/internal/exec-plans/release-v1.118.0-plan.md @@ -0,0 +1,58 @@ +# Lime v1.118.0 发布执行计划 + +状态:completed +日期:2026-08-01 +目标版本:`1.118.0` +目标 tag:`v1.118.0` + +## 主目标 + +将 `v1.117.0` 后当前工作树中的 Skills catalog 自动失效、typed error、`turn/plan/updated`、MCP OAuth 完成通知、Windows Provider 模型目录与 updater 可靠性,以及核心用户流程修复作为同一个 release candidate,完成版本事实源、双语 release notes、发布门禁、release commit、tag、`main`/tag 推送与远端复核。 + +## 当前阶段与下一刀 + +- 当前阶段:`v1.118.0` release candidate、版本事实源、双语 release notes、发布门禁、release commit、tag、`main`/tag 推送与远端引用复核均已完成。 +- 下一刀:无;后续增量进入下一版本,不改写已发布的 `v1.118.0` tag。 + +## Release Candidate + +- 基线:`v1.117.0`。 +- 最终候选盘点:167 个 tracked diff 文件、30 个未跟踪文件,tracked diff 为 12,614 行新增、1,563 行删除。 +- `release metadata`:根 `package.json`、`packages/lime-cli-npm/package.json`、`lime-rs/Cargo.toml`、`lime-rs/Cargo.lock`、双语 release notes 与本计划。 +- `candidate changes`:当前工作树全部产品、协议、文档、测试、schema、生成物和脚本改动;重点包括 Skills catalog invalidation、typed error retry/terminal、canonical plan checklist、MCP OAuth typed notification、Provider model catalog 和 updater 状态修复。 +- `excluded changes`:无。初始盘点未发现缓存、构建产物、凭证、个人临时文件或与候选无关的大文件。 +- 并行避让:本轮除 release metadata、本计划和 Rust 门禁暴露的一处过时 fixture 外,其余候选文件只读验证,不覆盖已有业务改动。门禁期间新增的 MCP 架构/进度文档属于同一 current notification 候选,已纳入最终盘点与后续验证。 + +## 写集与退出条件 + +- 所有版本事实源同步为 `1.118.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`。 +- 协议、App Server、Electron、GUI、脚本与重大架构均有变化;门禁失败必须定位修复或记录明确阻塞,不能静默降级。 +- 重大架构变更已同步 `internal/aiprompts/architecture.md`,并包含责任开发者确认。 +- Git 写操作已获得明确确认;完整 release candidate 由同一 release commit 承载,`v1.118.0` tag、远端 `main` 与远端 tag 指向同一发布对象。 + +## 验证记录 + +- `npm run verify:app-version`:版本更新后与最终复跑均通过,根应用、CLI npm 包、Rust workspace 与锁文件一致为 `1.118.0`。 +- `npm run typecheck`:通过,覆盖 renderer 与 Node TypeScript 配置。 +- `npm run test:contracts`:通过;839 个 generated protocol types、0 generation failure、292 项 App Server client contract,以及 command、harness、modality、scripts、Electron release workflow 和 docs boundary guard 全部通过。 +- `npm run test:rust:changed`:因 workspace 版本和 lockfile 变化扩大为 `cargo test --lib --workspace`。首次运行发现 `maps_item_and_terminal_lifecycle_to_direct_v2` 仍使用旧 `turn.failed` fixture;更新为当前 `error -> turn/completed` contract 后,定向测试 1/1 与最终 workspace 全部 suite 通过,关键结果包括 App Server 1650/1650、protocol 98/98、MCP 151/151,0 failed;既有联网 embedding 测试按规则 ignored。 +- `cargo fmt --manifest-path "lime-rs/Cargo.toml" --all --check` 与 `git diff --check`:通过。 +- `npm run smoke:agent-runtime-current-fixture`:通过;覆盖 history/cache hydration、停止继续、approval、Skills catalog 自动刷新、typed error retry success/failure、Plan 历史恢复、MCP structuredContent、media、Coding Workbench 与 Article Editor,`liveProviderUsed=false`。 +- `npm run verify:gui-smoke`:通过;App Server 报告 `version=1.118.0`,真实 Electron Gate B 摘要位于 `.lime/qc/project-gates/standalone-shell-01-20260731155713-55975/shell-01-electron-smoke/summary.json`。 +- `npm run smoke:mcp-oauth-notification-electron-fixture`:通过;真实 Electron、外部浏览器回调与 App Server v2 notification 闭环命中 `mcpServer/oauthLogin/completed`,证据位于 `.lime/qc/mcp-oauth-notification/mcp-oauth-notification-fixture-summary.json`。 +- 工作树稳定性:2026-08-01 00:01:15 与 00:01:34 两次采样的 status、tracked diff、untracked content 三组 SHA-256 完全一致;最终候选保持 167 个 tracked diff 文件与 30 个未跟踪文件。本地 `HEAD`、`origin/main` 与远端 `main` 均为 `66752a277`,目标 tag 仍不存在。 +- Staged 边界:197 个候选文件全部暂存,摘要为 16,356 行新增、1,563 行删除;未暂存与未跟踪文件均为 0,`git diff --cached --check` 通过。 +- 发布结果:release commit、`v1.118.0` tag、`main`/tag 推送与远端引用复核完成;发布后工作树 clean。 +- 平台限制:Windows Squirrel packaged Gate B 尚未执行;macOS Electron fixture 不能替代 Windows 实机证据。 + +## 分类 + +- `current`:Electron Desktop Host -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item -> GUI 产品链;typed v2 notifications、Skill catalog invalidation、canonical checklist/error recovery、Provider 模型目录和 Electron updater。 +- `compat`:无新增。 +- `deprecated`:Refactor v2 尚未迁完的 V2-05 notification、host capability 与 recovery surface,只允许继续迁出。 +- `dead / deleted / forbidden-to-restore`:raw notification wrapper、第二 catalog/checklist/error owner、Provider inferred-only 可执行假设、updater 伪版本判断与生产 mock fallback。 + +## 完成度 + +- 当前完成度:100%。候选范围、发布元数据、发布门禁、staged 复核、commit、tag、push 与远端复核均已完成。 diff --git a/internal/exec-plans/windows-model-catalog-and-updater-reliability.md b/internal/exec-plans/windows-model-catalog-and-updater-reliability.md new file mode 100644 index 000000000..617868a3e --- /dev/null +++ b/internal/exec-plans/windows-model-catalog-and-updater-reliability.md @@ -0,0 +1,67 @@ +# Windows 模型目录与更新可靠性闭环 + +状态:实现完成;Windows Gate B 待实机验收 +日期:2026-07-31 + +## 目标 + +1. Windows 用户添加并启用 Provider 模型后,聊天模型选择器必须展示该显式模型。 +2. 更新入口只在确认候选版本严格高于当前版本后出现。 +3. 更新失败、已是最新和发现新版本使用不同状态,不再把失败或同版本误报成新版本。 + +## 用户闭环 + +用户从设置中的 Provider 添加模型和凭证,回到聊天输入区打开模型选择器,能看到并选择刚添加的模型。应用后台检查更新时不打扰用户;只有已确认的新版本可安装时才显示更新入口,失败时提供准确状态和可恢复动作。 + +## 写集 + +- `src/components/input-kit/ModelSelector.tsx` 及定向测试 +- `src/components/agent/chat/utils/providerModelCompatibility.ts` 及定向测试 +- `electron/updateHost.ts` 及定向测试 +- `src/components/app-sidebar/AppUpdateEntry.tsx` 及定向测试 +- `src/pages/update-notification.tsx` 及定向测试 +- `src/i18n/resources/*/common.json` 的更新状态文案 +- 本执行记录 + +避让:App Server protocol、Skills runtime、`internal/aiprompts/architecture.md` 与已有脏执行计划。 + +## 根因证据 + +- 模型:Provider 显式模型会被前端临时投影为无权威能力来源的元数据,`ModelSelector` 又把该来源当成路由不可执行并直接过滤,形成“顶部保留旧选择、候选列表为空”。 +- 更新:`update-available` 事件没有版本参数,旧逻辑以 `undefined !== currentVersion` 判断有更新;通知页又固定渲染“发现新版本”,未按 `failed / up_to_date / completed` 分支。 + +## 退出条件 + +- Provider 显式模型可见,未显式声明且仅靠启发式推断的目录项仍 fail closed。 +- 同版本、旧版本、无版本和无效版本均不会产生可安装更新入口。 +- 只有严格更高版本进入已下载/可安装状态;失败通知不显示“发现新版本”。 +- 定向单测、`typecheck`、`test:contracts`、`verify:app-version` 和 `verify:gui-smoke` 完成或记录明确阻塞。 + +## 验证记录 + +- 模型定向回归:`providerModelCompatibility.test.ts` 与 `ModelSelector.test.tsx`,48/48 通过。 +- 更新与模型联合定向回归:7 个测试文件,99/99 通过。 +- Electron 版本与宿主定向回归:23/23 通过。 +- `npm run typecheck`:通过。 +- `npm run typecheck:electron`:通过。 +- `npm run verify:app-version`:通过,当前版本 `1.117.0`。 +- `npm run test:contracts`:通过。 +- `npm run verify:gui-smoke`:通过;Electron、preload/IPC、App Server JSON-RPC 与用户可见工作台主路径 smoke 证据:`.lime/qc/project-gates/standalone-shell-01-20260731050343-95334/shell-01-electron-smoke/summary.json`。 +- 2026-07-31 最终 GUI smoke:21/21 assertions 通过,console / invoke / preload / page error 均为 0,mock fallback 与 legacy command 命中为 0;证据:`.lime/qc/project-gates/standalone-shell-01-20260731103342-78558/shell-01-electron-smoke/summary.json`。 +- updater / About / UpdateNotification / Sidebar 定向回归:51/51 通过;`npm run typecheck:electron` 与 `npm run verify:app-version` 通过,版本 `1.117.0`。 +- `npm run smoke:agent-runtime-current-fixture`:通过;Provider 模型选择后的 current Agent 主链、历史、停止继续、typed error success/failure 与 read model terminal 未出现回归,`liveProviderUsed=false`。 +- `npm run verify:local`:通过;前端 112/112 个智能测试批次、Rust workspace 测试和内含 GUI smoke 全部通过。 +- 线上 Windows x64 `RELEASES` 清单:本机到 `updates.limecloud.com` 连续三次 TLS 握手失败,未取得服务端内容。 +- 待实机验收:Windows Squirrel 从 N-1 版本发现更新、下载、重启安装并确认最终版本的 Gate B 闭环。 +- 当前完成度:92%;实现、macOS Electron Gate B 与跨层回归已完成,剩余 8% 为 Windows packaged 平台验收,不能由 macOS fixture 代替。 + +## 状态分类 + +- current:Provider 配置 -> 模型目录 -> 模型选择器;Electron `autoUpdater` -> 更新会话 -> GUI。 +- compat:无新增。 +- deprecated:无新增。 +- dead / deleted:删除 `undefined !== currentVersion` 伪版本判断,以及版本未知时的用户可见更新入口。 + +## 架构确认 + +未改变 Electron Desktop Host、preload/IPC 或 App Server 边界,不属于重大架构变更,无需更新架构图。 diff --git a/internal/refactor/v2/EVENT-PROJECTIONS.md b/internal/refactor/v2/EVENT-PROJECTIONS.md index 78d02b206..215fed68d 100644 --- a/internal/refactor/v2/EVENT-PROJECTIONS.md +++ b/internal/refactor/v2/EVENT-PROJECTIONS.md @@ -13,20 +13,20 @@ 出口代码:TL 时间线,TP Turn 面板,PI pending interaction,HS Header/状态区,GN 应用通知,DX 仅开发诊断。 -实施快照:direct `item/started -> item/commandExecution/outputDelta* -> item/completed` 通过 typed adapter 和共享 reducer;production `thread/resume` 安装同一 replay reducer,后续 live notification 继续复用。completed snapshot 权威覆盖 delta 草稿,输出限制为 256 KiB。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 和共享 reducer;production `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 标记完成。 ## 1. Thread、Turn 与 Hook | # | Method | 目标出口 | 当前裁决 | v2 投影 | | --: | ------------------------------- | -------- | -------- | ----------------------------------------------------- | -| 1 | error | TP/HS | planned | thread/turn 错误;willRetry 只显示重试中 | +| 1 | error | TP/HS | current | typed live/durable;true 重试,false 等权威 Turn | | 2 | thread/started | HS | current | 建立 Thread metadata,不把空 turns 当完整 history | | 3 | thread/status/changed | HS/PI | current | notLoaded、idle、systemError、active 与 waiting flags | | 4 | thread/archived | GN/HS | current | 侧栏归档,当前页只读 | | 5 | thread/deleted | GN | current | 当前页已删除态,不删除 Project | | 6 | thread/unarchived | GN/HS | current | 恢复可见/可操作状态 | | 7 | thread/closed | HS | current | 清除 live spinner 和 pending interaction | -| 8 | skills/changed | GN | planned | 刷新 Composer Skill catalog | +| 8 | skills/changed | GN | current | 瞬时失效并重新读取 Composer current Skill catalog | | 9 | thread/name/updated | HS | current | Header 与侧栏名称 | | 10 | thread/goal/updated | HS | current | goal/阶段入口,不映射 Rust ProductionPlan | | 11 | thread/goal/cleared | HS | current | 清除当前 goal indicator | @@ -39,7 +39,7 @@ | 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 | -| 21 | turn/plan/updated | TP | planned | 执行 checklist,不等同 plan Item | +| 21 | turn/plan/updated | TP | current | canonical update_plan checklist,实时/冷恢复一致 | ## 2. Item 生命周期、流与进程 @@ -67,7 +67,7 @@ | # | Method | 目标出口 | 当前裁决 | v2 投影 | | --: | ------------------------------------ | -------- | ---------------------- | ---------------------------------------- | -| 39 | mcpServer/oauthLogin/completed | GN/PI | planned | 结束 OAuth 等待并刷新 MCP status | +| 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 | 若未来纳入,独立产品范围变更 | @@ -167,7 +167,7 @@ command/file approval、requestUserInput、MCP elicitation 与 permission approv ## 9. 非 Item 投影与状态终结 -Turn plan 是执行 checklist,不是 ThreadItem.plan 或 Rust ProductionPlan。Turn diff 是本 Turn 聚合 Diff,不能取代 fileChange Item。Header 只显示当前 model、reasoning、permission/sandbox、environment 与 active flags 的用户级摘要;底层 route、provider、credential 和 capability 明细进入受控运行详情。 +Turn plan 是 canonical `update_plan` 的执行 checklist,不是 ThreadItem.plan 或 Rust ProductionPlan;实时 `turn.plan.updated` 与 cold read 从同一结构化快照恢复,非法快照不覆盖上一份有效计划。Turn diff 是本 Turn 聚合 Diff,不能取代 fileChange Item。Header 只显示当前 model、reasoning、permission/sandbox、environment 与 active flags 的用户级摘要;底层 route、provider、credential 和 capability 明细进入受控运行详情。 | 触发 | 必须终结的 UI | | --------------------------- | ----------------------------------------------------- | diff --git a/internal/refactor/v2/IMPLEMENTATION-PLAN.md b/internal/refactor/v2/IMPLEMENTATION-PLAN.md index d041589f8..0d6df1a91 100644 --- a/internal/refactor/v2/IMPLEMENTATION-PLAN.md +++ b/internal/refactor/v2/IMPLEMENTATION-PLAN.md @@ -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 与 recovery;V2-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 Gate B 已通过 +当前阶段:V2-05 notification、host capability 与 recovery;V2-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 已关闭 media transient bypass、`configWarning` owner 迁移与 thread-scoped `warning` typed/recovery 链;继续审计 `skills/changed` 等具备真实 producer 的 notification,`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。不重复改写已关闭的 V2-04 owner,也不恢复 Message synthesis、extension fallback、v0 media/config owner、旧 Team 工具或第二 pending store。 +下一刀:V2-05 已关闭 media transient bypass、`configWarning` owner 迁移、thread-scoped `warning` typed/recovery、`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` checklist 与 MCP 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 的旁路状态机。 ## 1. 约束与非目标 @@ -252,14 +252,14 @@ 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、六工具 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 旁路、裸旧 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 `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 | -计划完成度:保守估算 91%。该数字按 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 链已关闭;其余 planned notification、host capability 与全面 recovery 仍未完成。该估算不表示 v2 已可整体交付或 release-ready。 +计划完成度:保守估算 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 checklist 与 MCP OAuth typed completion 链已关闭;其余 planned notification、host capability 与全面 recovery 仍未完成。该估算不表示 v2 已可整体交付或 release-ready。 ## 6. 执行台账 @@ -631,10 +631,90 @@ Gate B 证据: - full canonical 冷读与 history-limit projection summary 都从同一 durable warning event 派生历史 warning item,并保留 `code`;没有新增第二 persistence 或 canonical Item lifecycle 变体。 - 实时与恢复都只接受精确 message/code shape;畸形 message/code、旧 alias、缺失或不匹配的 thread/turn identity 均 fail closed。raw `agentSession/event` 包装的 `runtime.warning` 被拒绝。`guardianWarning` 因没有 Guardian runtime producer 继续保持 planned。 -验证:protocol v2 50/50、schema fixture 1/1、App Server warning 12/12、projection-summary recovery 1/1、Renderer 3 files / 61 tests、generated TypeScript drift check 均通过。`npm run test:contracts` 通过 289 项 app-server-client checks 及全部附属 guards;Rust related 覆盖 19 个 scoped packages、18 个 lib 目标,共 3597 项(3593 passed、4 个既有环境/live 测试 ignored、0 failed)。`npm run smoke:agent-runtime-current-fixture` 完整通过且 `liveProviderUsed=false`;该聚合 smoke 没有普通 warning 专项 DOM 断言,不冒充 warning 专项 Gate B。通用 `npm run verify:gui-smoke` 21/21 assertions 通过,证据为 `.lime/qc/project-gates/standalone-shell-01-20260730191439-55464/shell-01-electron-smoke/summary.json`。`cargo fmt --all --check`、定向 Prettier、`git diff --check` 与 `npm run governance:legacy-report` 通过,治理结果为零引用候选 0、分类漂移 0、边界违规 0。 +验证:protocol v2 50/50、schema fixture 1/1、App Server warning 12/12、projection-summary recovery 1/1、Renderer 3 files / 61 tests、generated TypeScript drift check 均通过。`npm run test:contracts` 通过 289 项 app-server-client checks 及全部附属 guards;Rust related 覆盖 19 个 scoped packages、18 个 lib 目标,共 3597 项(3593 passed、4 个既有环境/live 测试 ignored、0 failed)。`npm run smoke:agent-runtime-current-fixture` 完整通过且 `liveProviderUsed=false`;该聚合 smoke 没有普通 warning 专项 DOM 断言,不冒充 warning 专项 Gate B。fresh `npm run verify:local` 通过 i18n、lint、typecheck、112/112 Vitest、contracts、Rust changed-scope、Electron renderer/host build 与真实 GUI smoke;后者 21/21 assertions 通过,证据为 `.lime/qc/project-gates/standalone-shell-01-20260730194114-24572/shell-01-electron-smoke/summary.json`。`cargo fmt --all --check`、定向 Prettier、`git diff --check` 与 `npm run governance:legacy-report` 通过,治理结果为零引用候选 0、分类漂移 0、边界违规 0。 治理分类:v2 `warning` protocol/projector、thread-scoped Renderer toast 与 durable recovery 为 `current`;无 `compat`;raw warning wrapper 为 `dead / forbidden-to-restore`;`guardianWarning` 为 `planned`。`v2_notifications.rs` 已超过 1000 行且主要由 inline tests 构成,本刀只增加路由并把业务 projector 放入独立 `warning.rs`;后续触碰 inline test 区前应迁出专用测试模块。 架构确认:typed notification/recovery owner 已同步 `internal/aiprompts/architecture.md` 第 27 节;唯一产品链保持不变。责任开发者确认:root,2026-07-31。 下一刀:审计 `skills/changed` 与其真实 Skill catalog producer/consumer;只有能形成 typed producer -> GUI refresh -> Gate B 的垂直切片才改为 current。v2 总体完成度保守估算约 91%。 + +### 2026-07-31:V2-05 `skills/changed` catalog invalidation + +状态:该垂直切片 `completed`;V2-05 与 v2 总体仍为 `in-progress`,不得标记 release-ready。 + +本轮实现: + +- v2 protocol、schema、generated client 与 typed decoder 只接受 `skills/changed {}`;默认 Skill roots 的创建、修改、删除以及启动后首次出现都经 10 秒节流 watcher 失效 `lime-skills` snapshot cache 并广播通知。 +- 成功的 Lime catalog mutation 在 App Server processor 边界失效同一 cache 并附带 typed notification;失败 mutation 和其他 app 的 mutation 不发通知。 +- Composer `useLimeSkills` 的自动加载与 notification refresh 统一读取 current `skill/list`;管理中心 `skillManagement/list` 语义不变,`skillsApi.getRuntimeCatalog()` 复用既有 executable Skill decoder 与纯 catalog projection,不新增第二 catalog owner。 +- Renderer 只订阅 typed event bus;命中后记录 console-only `skillsChanged.received` marker 并自动重读,重连/重新挂载仍主动 list。该 notification 是进程级瞬时失效,不写入 Thread/Turn/Item、持久化或 recovery replay。 + +验证:纯 projection、API、Hook 与 fixture guard 共 4 files / 99 tests 通过;`npm run typecheck`、`npm run test:contracts`(840 schema definitions、832 generated types、0 generation failures、290 app-server-client checks)、`npm run governance:legacy-report`、Prettier、MJS `node --check`、`cargo fmt --all --check` 与 `git diff --check` 通过。Rust workspace lib 汇总为 32 个 suite、4670 passed、5 ignored、0 failed。`npm run test:related` 因仓库既有 runner 把 `electron/` 目录当文件读取而报 `EISDIR`,未记为通过,已由精确 Vitest 覆盖本轮前端回归。 + +产品证据:`npm run smoke:claw-chat-current-fixture -- --scenario skills-runtime` 通过,proof level 为 `Gate B controlled fixture`。真实 Electron/preload/IPC/App Server 链先产生初始 `skill/list`,随后隔离 HOME 默认 root 新建 `notification-refresh/SKILL.md`,typed `skills/changed` marker 为 1,`skill/list` 自动由 1 次增至 2 次且 transport 为 `electron-ipc`,GUI 新 Skill 可见,手动刷新点击为 0;console/page/actionable errors 与 failed assertions 均为 0。证据:`.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-summary.json`。 + +整合复验:聚合 `npm run smoke:agent-runtime-current-fixture` 完整通过且 `liveProviderUsed=false`,包含 `skills/changed`、Inputbar rich restore、Skills Runtime 三入口及其余 current Agent/Electron fixture。首次聚合运行暴露的 Inputbar 失败属于 fixture DOM 定位合同:夹具用展示名 `Capability Report` 大小写敏感匹配 current catalog 的 `capability-report`,因此候选已可见但未点击;生产 CharacterMention 与 catalog identity 无缺陷。夹具现统一折叠大小写、空格、连字符和下划线,并用 source guard 锁定候选与 `@capability-report` badge 的同一等价规则。独立 `inputbar-rich-restore` Gate B 随后通过,text/image/path/skill 在 output-free cancel 后全部恢复,console/page error 与 mock fallback 为 0;证据为 `.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-inputbar-rich-restore-regression-summary.json`。 + +通用 `npm run verify:gui-smoke` 通过,真实 Electron renderer、App Server sidecar、Claw shell reload 与 memory settings 均就绪;证据为 `.lime/qc/project-gates/standalone-shell-01-20260731043417-63564/shell-01-electron-smoke/summary.json`。fresh `npm run verify:local` 通过版本一致性和 i18n unused 后,在全仓 lint 被非本切片脏文件 `src/components/input-kit/ModelSelector.tsx:432` 的既有 `react-hooks/exhaustive-deps` warning 阻断,因此未记为通过;本切片未改该文件或越界修复。后续独立 `npm run typecheck`、contracts、99 项精确测试、GUI smoke、Rust fmt 与 Gate B 均已取得明确通过结果。 + +治理分类:v2 `skills/changed` protocol/schema、App Server watcher/mutation producer、`lime-skills` cache invalidation、typed Renderer event bus 与 current `skill/list` GUI refresh 为 `current`;无 `compat`;`skillManagement/list` 仅保留管理中心原语义,不是 Composer catalog owner;持久化/replay、`skills/extraRoots/set`、第二 catalog owner 与生产 mock fallback 均未新增。 + +架构确认:catalog invalidation owner 已同步 `internal/aiprompts/architecture.md` 第 28 节;唯一产品链保持不变。责任开发者确认:root,2026-07-31。 + +下一刀:回到 `EVENT-PROJECTIONS.md` 审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recovery;`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 92%。 + +### 2026-07-31:V2-05 typed `error` retry/terminal closure + +状态:该垂直切片 `completed`;V2-05 与 v2 总体仍为 `in-progress`,不得标记 release-ready。 + +本轮实现: + +- v2 protocol、schema、generated client 与 strict Renderer decoder 承接 typed `error`;App Server projector 将 durable `runtime.error` 投影到同一 thread/turn identity,raw `agentSession/event` 不再是 GUI owner。 +- `willRetry=true` 只进入可见重试状态,后续权威 `turn/completed(completed)` 清理提示并完成 Turn;一个或多个 retry error 后的 `willRetry=false` 只显示 terminal error,最终失败仍由权威 `turn/completed(failed)` 决定。 +- canonical full/limited read model 统一合入 durable runtime warning 与 error。恢复出的 runtime error 保留可见错误证据,但 Turn 保持 running,直到 durable Turn terminal 到达;没有新增第二 persistence、第二 terminal reducer 或 error 合成终态。 +- SDK 将 typed error 放在独立 signal 通道,不送入只接受 lifecycle sequence 的 verifier。Gate B assertion 把 provider first-text trace 与 App Server message delta、Renderer output/paint、current trace method、W3C 和 identity 证据拆分;typed-error 场景只豁免本来不会发生的 provider 首文本证据,不能豁免 current 主链证据。 + +验证:typed-error Renderer/fixture 共 6 files / 94 tests、`packages/app-server-client` 8 files / 98 tests、`packages/agent-runtime-client` 22 tests 通过;Rust canonical runtime error recovery 与 `runtime_error_does_not_preempt_a_later_turn_failed_terminal` 两项定向回归分别通过。`npm run test:contracts` 通过,app-server-client contract 为 292 checks;`npm run smoke:agent-runtime-current-fixture` 完整通过且 `liveProviderUsed=false`;`npm run verify:gui-smoke` 21/21 assertions 通过,证据为 `.lime/qc/project-gates/standalone-shell-01-20260731104641-30858/shell-01-electron-smoke/summary.json`。 + +产品证据:success 场景经真实 Electron、preload/IPC、`app_server_handle_json_lines`、App Server、RuntimeCore、read model 与 GUI 观察 `error(willRetry=true) -> turn/completed(completed)`,同一 identity、零生产 mock fallback,证据为 `.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-typed-error-retry-success-gateb-02-summary.json`。failure 场景经同一链观察 `error(true)* -> error(false) -> turn/completed(failed)`,pending read model、GUI failed 与最终 read-model/后台 terminal 一致,证据为 `.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-typed-error-retry-failure-gateb-03-summary.json`。两者 `ok=true`、proof level 均为 `Gate B controlled fixture`;聚合回归证据分别为 `claw-chat-current-fixture-typed-error-retry-success-regression-summary.json` 与 `claw-chat-current-fixture-typed-error-retry-failure-regression-summary.json`。 + +治理分类:v2 typed `error` protocol/projector、durable runtime error recovery、strict Renderer signal 与权威 Turn terminal ownership 为 `current`;无 `compat`;raw error wrapper、provider trace 对 current 主链证据的错误替代和由 error 抢占 Turn terminal 的第二状态机为 `dead / forbidden-to-restore`;其余 V2-05 planned notification、host capability 与全面 recovery 仍为开放项。 + +架构确认:本切片没有改变 public owner、唯一产品链或依赖方向,只补齐既有 notification/projector/read-model/Renderer 边界的 typed contract 与恢复语义;无需改写 `internal/aiprompts/architecture.md`。责任开发者确认:root,2026-07-31。 + +下一刀:回到 `EVENT-PROJECTIONS.md` 审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recovery;`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 93%。 + +### 2026-07-31:V2-05 `turn/plan/updated` canonical checklist 与恢复 + +状态:该垂直切片 `completed`;V2-05 与 v2 总体仍为 `in-progress`,不得标记 release-ready。 + +本轮实现: + +- App Server 从 canonical `update_plan` `ToolOutput.structured_content` 派生 typed `turn.plan.updated`;Renderer 严格解析该通知并把实时快照投影到唯一 run-control checklist owner。 +- canonical cold read 从成功的 `update_plan` 工具项恢复最新有效计划,兼容 App Server 真实 wire 的 `[ { name: "plan", value: "[...]" } ]` 参数形状;空数组可以清空旧 checklist,失败或非法快照不会覆盖上一份有效计划。 +- read model 保留 canonical `update_plan` 工具项以支持恢复,但不生成 `ThreadItem.plan`,不从 Message/tool card 合成 checklist,也不展示 Plan UI、decision panel 或 `update_plan` 工具行。 + +验证:`appServerCanonicalThreadProjection.test.ts` 10/10;`npm run test:contracts` 通过,app-server-client contract 为 292 checks;`npm run typecheck`、`npm run smoke:agent-runtime-current-fixture` 与 `npm run verify:gui-smoke` 均通过。Gate B 证据为 `.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-summary.json`:`scenario=turn-plan-update`、`proofLevel=Gate B controlled fixture`、`methodCount=1`、通知位于事件序列 `planIndex=16` 且 `turn/completed` 位于 `terminalIndex=27`;实时与 reload 后均可见 2 项 checklist,canonical read model `updatePlanToolCount=1`、`planItemCount=0`,Plan block/decision/update_plan tool row 均为 0,Electron IPC、App Server、read model 与 GUI identity 一致,invoke/page/console error 为 0。 + +治理分类:`turn/plan/updated` typed producer、strict Renderer parser、canonical `update_plan` cold recovery 与 run-control checklist 为 `current`;无 `compat`;`update_plan` 场景下的 Plan ThreadItem、Message/tool card 合成、Plan UI/decision panel/tool card 和 renderer mock fallback 均为 `dead / forbidden-to-restore`,独立 Plan mode 的现役 UI 不在本切片范围内。本切片没有改变 public owner、唯一产品链或依赖方向,无需改写 `internal/aiprompts/architecture.md`。 + +下一刀:回到 `EVENT-PROJECTIONS.md` 审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recovery;`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 94%。 + +### 2026-07-31:V2-05 MCP OAuth typed completion notification + +状态:该垂直切片 `completed`;V2-05 与 v2 总体仍为 `in-progress`,不得标记 release-ready。 + +本轮实现: + +- Rust MCP OAuth 返回 `McpOAuthLoginHandle` completion future;App Server 等待 callback 成功或失败后发布 `mcpServer/oauthLogin/completed`,payload 固定为必填 `name`、必填可空 `threadId`、`success` 与可选 `error`。 +- protocol v2、schema、generated TypeScript 与 app-server-client strict decoder 使用同一 typed contract。Renderer 在任何异步 Desktop listener 注册前同步订阅 App Server event bus,避免快速 callback 被排空,并在完成后自动刷新 `mcpServerStatus/list` 与 `mcpTool/list`。 +- 生产路径已删除 `mcp:oauth_completed`;contract guard 禁止该字符串回流 `electron`、`lime-rs`、`packages` 与 `src`。没有新增 compat wrapper、第二 OAuth 状态 owner或生产 mock fallback。 + +验证:`lime-mcp` OAuth 22 passed、app-server-protocol v2 59 passed、App Server MCP processor 4 passed、app-server-client 101 passed、`src/hooks/useMcp.test.tsx` 9 passed;`npm run test:contracts` 通过,包含 847 schema definitions、839 generated protocol types、0 generation failures 与 292 app-server-client checks。 + +产品证据:`npm run smoke:mcp-oauth-notification-electron-fixture` 通过,proof level 为 `Gate B`。真实 Electron/preload/IPC、`app_server_handle_json_lines`、App Server OAuth login/event drain、本地 OAuth provider callback 与 Renderer GUI 链全部命中;授权前后状态和 completion toast 均可见,`mcpServer/oauth/login`、`mcpServerStatus/list`、`mcpTool/list` 自动刷新成立,`openExternalUrlHitCount=1`、`electronIpcHitCount=8`、`mockFallbackHitCount=0`、`failedInvokeCount=0`,console/page/invoke errors 均为 0。证据为 `.lime/qc/mcp-oauth-notification/mcp-oauth-notification-fixture-summary.json` 与同目录 PNG。 + +治理分类: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`。责任开发者确认:root,2026-07-31。 + +下一刀:回到 `EVENT-PROJECTIONS.md` 继续审计具备真实 producer/consumer 的 planned notification、host capability 或 recovery;`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 95%,仍非 release-ready。 diff --git a/lime-rs/Cargo.lock b/lime-rs/Cargo.lock index 442f0af8e..2f963e6f8 100644 --- a/lime-rs/Cargo.lock +++ b/lime-rs/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-protocol" -version = "1.117.0" +version = "1.118.0" dependencies = [ "schemars", "serde", @@ -30,7 +30,7 @@ dependencies = [ [[package]] name = "agent-runtime" -version = "1.117.0" +version = "1.118.0" dependencies = [ "agent-protocol", "anyhow", @@ -181,7 +181,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app-server" -version = "1.117.0" +version = "1.118.0" dependencies = [ "agent-protocol", "agent-runtime", @@ -209,6 +209,7 @@ dependencies = [ "lime-services", "lime-skills", "model-provider", + "notify", "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", @@ -241,7 +242,7 @@ dependencies = [ [[package]] name = "app-server-client" -version = "1.117.0" +version = "1.118.0" dependencies = [ "app-server-protocol", "app-server-transport", @@ -252,7 +253,7 @@ dependencies = [ [[package]] name = "app-server-daemon" -version = "1.117.0" +version = "1.118.0" dependencies = [ "app-server-protocol", "app-server-transport", @@ -264,7 +265,7 @@ dependencies = [ [[package]] name = "app-server-protocol" -version = "1.117.0" +version = "1.118.0" dependencies = [ "agent-protocol", "schemars", @@ -274,7 +275,7 @@ dependencies = [ [[package]] name = "app-server-test-client" -version = "1.117.0" +version = "1.118.0" dependencies = [ "anyhow", "app-server-client", @@ -285,7 +286,7 @@ dependencies = [ [[package]] name = "app-server-transport" -version = "1.117.0" +version = "1.118.0" dependencies = [ "app-server-protocol", "axum", @@ -1355,7 +1356,7 @@ dependencies = [ [[package]] name = "document-preview" -version = "1.117.0" +version = "1.118.0" dependencies = [ "flate2", "tempfile", @@ -2618,7 +2619,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.117.0" +version = "1.118.0" dependencies = [ "agent-protocol", "agent-runtime", @@ -2651,7 +2652,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.117.0" +version = "1.118.0" dependencies = [ "chrono", "futures", @@ -2668,7 +2669,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.117.0" +version = "1.118.0" dependencies = [ "clap", "lime-core", @@ -2680,7 +2681,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.117.0" +version = "1.118.0" dependencies = [ "async-trait", "lime-core", @@ -2696,7 +2697,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.117.0" +version = "1.118.0" dependencies = [ "agent-protocol", "async-trait", @@ -2752,7 +2753,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.117.0" +version = "1.118.0" dependencies = [ "aes", "async-trait", @@ -2780,7 +2781,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.117.0" +version = "1.118.0" dependencies = [ "chrono", "dashmap", @@ -2802,7 +2803,7 @@ dependencies = [ [[package]] name = "lime-knowledge" -version = "1.117.0" +version = "1.118.0" dependencies = [ "chrono", "hex", @@ -2815,7 +2816,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.117.0" +version = "1.118.0" dependencies = [ "async-trait", "axum", @@ -2841,7 +2842,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.117.0" +version = "1.118.0" dependencies = [ "axum", "base64 0.22.1", @@ -2860,7 +2861,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.117.0" +version = "1.118.0" dependencies = [ "async-trait", "lime-core", @@ -2900,7 +2901,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.117.0" +version = "1.118.0" dependencies = [ "agent-protocol", "async-stream", @@ -2943,7 +2944,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.117.0" +version = "1.118.0" dependencies = [ "axum", "futures", @@ -2958,7 +2959,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.117.0" +version = "1.118.0" dependencies = [ "anyhow", "async-trait", @@ -3001,7 +3002,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.117.0" +version = "1.118.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -3022,7 +3023,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.117.0" +version = "1.118.0" dependencies = [ "axum", "chrono", @@ -3231,7 +3232,7 @@ dependencies = [ [[package]] name = "model-provider" -version = "1.117.0" +version = "1.118.0" dependencies = [ "agent-protocol", "anyhow", @@ -3901,7 +3902,7 @@ checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" [[package]] name = "patch-apply" -version = "1.117.0" +version = "1.118.0" dependencies = [ "tempfile", "thiserror 1.0.69", @@ -4660,7 +4661,7 @@ dependencies = [ [[package]] name = "runtime-core" -version = "1.117.0" +version = "1.118.0" dependencies = [ "agent-protocol", "app-server-protocol", @@ -5408,7 +5409,7 @@ dependencies = [ [[package]] name = "thread-store" -version = "1.117.0" +version = "1.118.0" dependencies = [ "agent-protocol", "chrono", @@ -5686,7 +5687,7 @@ dependencies = [ [[package]] name = "tool-runtime" -version = "1.117.0" +version = "1.118.0" dependencies = [ "agent-protocol", "app-server-protocol", diff --git a/lime-rs/Cargo.toml b/lime-rs/Cargo.toml index 6785a56ae..d1c7b5da6 100644 --- a/lime-rs/Cargo.toml +++ b/lime-rs/Cargo.toml @@ -6,7 +6,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.117.0" +version = "1.118.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" diff --git a/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json b/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json index 2579016a2..a841bb29d 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json +++ b/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json @@ -13804,6 +13804,150 @@ "title": "ClientResponse", "type": "object" }, + "CodexErrorInfo": { + "$defs": { + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ], + "title": "CodexErrorInfo" + }, "CollabAgentState": { "$defs": { "CollabAgentStatus": { @@ -19788,6 +19932,204 @@ "title": "EndpointKind", "type": "string" }, + "ErrorNotification": { + "$defs": { + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, + "TurnError": { + "additionalProperties": false, + "properties": { + "additionalDetails": { + "type": [ + "null", + "string" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Reports a transient retry or terminal error for the current turn.", + "properties": { + "error": { + "$ref": "#/$defs/TurnError" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "willRetry": { + "description": "True only when App Server has already decided to retry this turn.", + "type": "boolean" + } + }, + "required": [ + "error", + "threadId", + "turnId", + "willRetry" + ], + "title": "ErrorNotification", + "type": "object" + }, "EvidenceExportParams": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -28290,6 +28632,37 @@ "title": "McpServerListResponse", "type": "object" }, + "McpServerOauthLoginCompletedNotification": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "error": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "threadId": { + "type": [ + "null", + "string" + ] + } + }, + "required": [ + "name", + "success", + "threadId" + ], + "title": "McpServerOauthLoginCompletedNotification", + "type": "object" + }, "McpServerOauthLoginParams": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -35917,6 +36290,15 @@ "title": "ModelVerificationNotification", "type": "object" }, + "NonSteerableTurnKind": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "enum": [ + "compact", + "review" + ], + "title": "NonSteerableTurnKind", + "type": "string" + }, "OpenDeepLinkPayload": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -43260,6 +43642,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -43579,6 +44094,32 @@ ], "type": "string" }, + "ErrorNotification": { + "additionalProperties": false, + "description": "Reports a transient retry or terminal error for the current turn.", + "properties": { + "error": { + "$ref": "#/$defs/TurnError" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "willRetry": { + "description": "True only when App Server has already decided to retry this turn.", + "type": "boolean" + } + }, + "required": [ + "error", + "threadId", + "turnId", + "willRetry" + ], + "type": "object" + }, "FileChangePatchUpdatedNotification": { "properties": { "changes": { @@ -43750,6 +44291,35 @@ ], "type": "object" }, + "McpServerOauthLoginCompletedNotification": { + "additionalProperties": false, + "properties": { + "error": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "threadId": { + "type": [ + "null", + "string" + ] + } + }, + "required": [ + "name", + "success", + "threadId" + ], + "type": "object" + }, "McpToolCallAppContext": { "properties": { "actionName": { @@ -44008,6 +44578,13 @@ ], "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -44194,6 +44771,11 @@ ], "type": "object" }, + "SkillsChangedNotification": { + "additionalProperties": false, + "description": "Signals that the process-wide Skill catalog must be read again.", + "type": "object" + }, "SleepItem": { "properties": { "durationMs": { @@ -45465,6 +46047,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -45472,7 +46055,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -45490,6 +46082,61 @@ ], "type": "string" }, + "TurnPlanStep": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/$defs/TurnPlanStepStatus" + }, + "step": { + "type": "string" + } + }, + "required": [ + "status", + "step" + ], + "type": "object" + }, + "TurnPlanStepStatus": { + "enum": [ + "completed", + "inProgress", + "pending" + ], + "type": "string" + }, + "TurnPlanUpdatedNotification": { + "additionalProperties": false, + "description": "Reports the latest turn-scoped execution checklist.", + "properties": { + "explanation": { + "default": null, + "type": [ + "null", + "string" + ] + }, + "plan": { + "items": { + "$ref": "#/$defs/TurnPlanStep" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "plan", + "threadId", + "turnId" + ], + "type": "object" + }, "TurnStartedNotification": { "description": "Notification payloads are kept beside the request contract so the v2\nprojection can use the same Thread/Turn identity without a second DTO.", "properties": { @@ -45715,6 +46362,54 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "error", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ErrorNotification" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "skills/changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SkillsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "mcpServer/oauthLogin/completed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/McpServerOauthLoginCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -45859,6 +46554,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "turn/plan/updated", + "type": "string" + }, + "params": { + "$ref": "#/$defs/TurnPlanUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -49079,6 +49790,13 @@ "title": "SkillWorkflowStep", "type": "object" }, + "SkillsChangedNotification": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Signals that the process-wide Skill catalog must be read again.", + "title": "SkillsChangedNotification", + "type": "object" + }, "SleepItem": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -49676,6 +50394,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -50096,6 +50947,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -50916,6 +51774,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -50923,7 +51782,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -51708,6 +52576,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -52152,6 +53153,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -53113,6 +54121,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -53120,7 +54129,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -57851,6 +58869,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -58271,6 +59422,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -59232,6 +60390,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -59239,7 +60398,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -59616,6 +60784,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -60036,6 +61337,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -60997,6 +62305,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -61004,7 +62313,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -61239,6 +62557,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -61659,6 +63110,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -62620,6 +64078,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -62627,7 +64086,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -63030,6 +64498,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -63474,6 +65075,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -64435,6 +66043,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -64442,7 +66051,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -64984,6 +66602,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -65404,6 +67155,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -66380,6 +68138,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -66387,7 +68146,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -66602,6 +68370,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -67022,6 +68923,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -67983,6 +69891,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -67990,7 +69899,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -68917,6 +70835,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -69361,6 +71412,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -70322,6 +72380,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -70329,7 +72388,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -70587,6 +72655,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -71007,6 +73208,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -71968,6 +74176,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -71975,7 +74184,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -72541,6 +74759,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -72938,6 +75289,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -73684,6 +76042,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -73691,7 +76050,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -73919,6 +76287,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -74339,6 +76840,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -75300,6 +77808,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -75307,7 +77816,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -75825,6 +78343,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -76222,6 +78873,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -76911,6 +79569,7 @@ ] }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -76918,7 +79577,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -77164,6 +79832,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -77561,6 +80362,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -78307,6 +81115,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -78314,7 +81123,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -78523,7 +81341,150 @@ "type": "object" }, "TurnError": { + "$defs": { + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + } + }, "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -78531,7 +81492,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -78574,6 +81544,103 @@ "title": "TurnItemsView", "type": "string" }, + "TurnPlanStep": { + "$defs": { + "TurnPlanStepStatus": { + "enum": [ + "completed", + "inProgress", + "pending" + ], + "type": "string" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/$defs/TurnPlanStepStatus" + }, + "step": { + "type": "string" + } + }, + "required": [ + "status", + "step" + ], + "title": "TurnPlanStep", + "type": "object" + }, + "TurnPlanStepStatus": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "enum": [ + "completed", + "inProgress", + "pending" + ], + "title": "TurnPlanStepStatus", + "type": "string" + }, + "TurnPlanUpdatedNotification": { + "$defs": { + "TurnPlanStep": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/$defs/TurnPlanStepStatus" + }, + "step": { + "type": "string" + } + }, + "required": [ + "status", + "step" + ], + "type": "object" + }, + "TurnPlanStepStatus": { + "enum": [ + "completed", + "inProgress", + "pending" + ], + "type": "string" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Reports the latest turn-scoped execution checklist.", + "properties": { + "explanation": { + "default": null, + "type": [ + "null", + "string" + ] + }, + "plan": { + "items": { + "$ref": "#/$defs/TurnPlanStep" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "plan", + "threadId", + "turnId" + ], + "title": "TurnPlanUpdatedNotification", + "type": "object" + }, "TurnStartParams": { "$defs": { "AdditionalContextEntry": { @@ -79007,6 +82074,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -79404,6 +82604,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -80150,6 +83357,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -80157,7 +83365,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -80357,6 +83574,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -80754,6 +84104,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -81500,6 +84857,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -81507,7 +84865,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -81974,6 +85341,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -82371,6 +85871,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -83117,6 +86624,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -83124,7 +86632,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -87382,6 +90899,7 @@ "enum": [ "agentSession/event", "configWarning", + "error", "initialized", "item/agentMessage/delta", "item/commandExecution/outputDelta", @@ -87393,11 +90911,13 @@ "item/reasoning/summaryTextDelta", "item/reasoning/textDelta", "item/started", + "mcpServer/oauthLogin/completed", "model/list/updated", "model/rerouted", "model/safetyBuffering/updated", "model/verification", "serverRequest/resolved", + "skills/changed", "thread/archived", "thread/closed", "thread/deleted", @@ -87410,6 +90930,7 @@ "thread/tokenUsage/updated", "thread/unarchived", "turn/completed", + "turn/plan/updated", "turn/started", "warning", "workspaceRightSurface/pendingChanged" diff --git a/lime-rs/crates/app-server-protocol/schema/json/manifest.json b/lime-rs/crates/app-server-protocol/schema/json/manifest.json index cefb1344a..26084cfe1 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/manifest.json +++ b/lime-rs/crates/app-server-protocol/schema/json/manifest.json @@ -218,6 +218,10 @@ "kind": "request", "method": "discordChannel/probe" }, + { + "kind": "notification", + "method": "error" + }, { "kind": "request", "method": "evidence/export" @@ -522,6 +526,10 @@ "kind": "request", "method": "mcpServer/oauth/login" }, + { + "kind": "notification", + "method": "mcpServer/oauthLogin/completed" + }, { "kind": "request", "method": "mcpServer/start" @@ -994,6 +1002,10 @@ "kind": "request", "method": "skillRepository/save" }, + { + "kind": "notification", + "method": "skills/changed" + }, { "kind": "request", "method": "soulStylePack/install" @@ -1186,6 +1198,10 @@ "kind": "request", "method": "turn/interrupt" }, + { + "kind": "notification", + "method": "turn/plan/updated" + }, { "kind": "request", "method": "turn/start" @@ -2190,6 +2206,7 @@ "ArtifactWriteSidecar", "ClientRequest", "ClientResponse", + "CodexErrorInfo", "CollabAgentState", "CollabAgentStatus", "CollabAgentTool", @@ -2212,6 +2229,7 @@ "DynamicToolNamespaceSpec", "DynamicToolNamespaceTool", "DynamicToolSpec", + "ErrorNotification", "FileChangeApprovalDecision", "FileChangePatchUpdatedNotification", "FileChangeRequestApprovalParams", @@ -2232,6 +2250,7 @@ "McpServerElicitationAction", "McpServerElicitationRequestParams", "McpServerElicitationRequestResponse", + "McpServerOauthLoginCompletedNotification", "McpToolCallAppContext", "McpToolCallError", "McpToolCallProgressNotification", @@ -2254,6 +2273,7 @@ "ModelUpgradeInfo", "ModelVerification", "ModelVerificationNotification", + "NonSteerableTurnKind", "PatchApplyStatus", "PatchChangeKind", "PermissionGrantScope", @@ -2268,6 +2288,7 @@ "ServerNotification", "ServerRequest", "ServerRequestResolvedNotification", + "SkillsChangedNotification", "SleepItem", "SortDirection", "SubAgentActivityKind", @@ -2379,6 +2400,9 @@ "TurnInterruptParams", "TurnInterruptResponse", "TurnItemsView", + "TurnPlanStep", + "TurnPlanStepStatus", + "TurnPlanUpdatedNotification", "TurnStartParams", "TurnStartResponse", "TurnStartedNotification", diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/CodexErrorInfo.json b/lime-rs/crates/app-server-protocol/schema/json/v2/CodexErrorInfo.json new file mode 100644 index 000000000..940c55aa2 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/CodexErrorInfo.json @@ -0,0 +1,144 @@ +{ + "$defs": { + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ], + "title": "CodexErrorInfo" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ErrorNotification.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ErrorNotification.json new file mode 100644 index 000000000..b17da5bb3 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -0,0 +1,198 @@ +{ + "$defs": { + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, + "TurnError": { + "additionalProperties": false, + "properties": { + "additionalDetails": { + "type": [ + "null", + "string" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Reports a transient retry or terminal error for the current turn.", + "properties": { + "error": { + "$ref": "#/$defs/TurnError" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "willRetry": { + "description": "True only when App Server has already decided to retry this turn.", + "type": "boolean" + } + }, + "required": [ + "error", + "threadId", + "turnId", + "willRetry" + ], + "title": "ErrorNotification", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/McpServerOauthLoginCompletedNotification.json b/lime-rs/crates/app-server-protocol/schema/json/v2/McpServerOauthLoginCompletedNotification.json new file mode 100644 index 000000000..c899a6fee --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/McpServerOauthLoginCompletedNotification.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "error": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "threadId": { + "type": [ + "null", + "string" + ] + } + }, + "required": [ + "name", + "success", + "threadId" + ], + "title": "McpServerOauthLoginCompletedNotification", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/NonSteerableTurnKind.json b/lime-rs/crates/app-server-protocol/schema/json/v2/NonSteerableTurnKind.json new file mode 100644 index 000000000..9f38e86f0 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/NonSteerableTurnKind.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "enum": [ + "compact", + "review" + ], + "title": "NonSteerableTurnKind", + "type": "string" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ServerNotification.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ServerNotification.json index 1cd382610..c4c83807c 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ServerNotification.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ServerNotification.json @@ -44,6 +44,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -363,6 +496,32 @@ ], "type": "string" }, + "ErrorNotification": { + "additionalProperties": false, + "description": "Reports a transient retry or terminal error for the current turn.", + "properties": { + "error": { + "$ref": "#/$defs/TurnError" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "willRetry": { + "description": "True only when App Server has already decided to retry this turn.", + "type": "boolean" + } + }, + "required": [ + "error", + "threadId", + "turnId", + "willRetry" + ], + "type": "object" + }, "FileChangePatchUpdatedNotification": { "properties": { "changes": { @@ -534,6 +693,35 @@ ], "type": "object" }, + "McpServerOauthLoginCompletedNotification": { + "additionalProperties": false, + "properties": { + "error": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "threadId": { + "type": [ + "null", + "string" + ] + } + }, + "required": [ + "name", + "success", + "threadId" + ], + "type": "object" + }, "McpToolCallAppContext": { "properties": { "actionName": { @@ -792,6 +980,13 @@ ], "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -978,6 +1173,11 @@ ], "type": "object" }, + "SkillsChangedNotification": { + "additionalProperties": false, + "description": "Signals that the process-wide Skill catalog must be read again.", + "type": "object" + }, "SleepItem": { "properties": { "durationMs": { @@ -2249,6 +2449,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -2256,7 +2457,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } @@ -2274,6 +2484,61 @@ ], "type": "string" }, + "TurnPlanStep": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/$defs/TurnPlanStepStatus" + }, + "step": { + "type": "string" + } + }, + "required": [ + "status", + "step" + ], + "type": "object" + }, + "TurnPlanStepStatus": { + "enum": [ + "completed", + "inProgress", + "pending" + ], + "type": "string" + }, + "TurnPlanUpdatedNotification": { + "additionalProperties": false, + "description": "Reports the latest turn-scoped execution checklist.", + "properties": { + "explanation": { + "default": null, + "type": [ + "null", + "string" + ] + }, + "plan": { + "items": { + "$ref": "#/$defs/TurnPlanStep" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "plan", + "threadId", + "turnId" + ], + "type": "object" + }, "TurnStartedNotification": { "description": "Notification payloads are kept beside the request contract so the v2\nprojection can use the same Thread/Turn identity without a second DTO.", "properties": { @@ -2499,6 +2764,54 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "error", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ErrorNotification" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "skills/changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SkillsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "mcpServer/oauthLogin/completed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/McpServerOauthLoginCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -2643,6 +2956,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "turn/plan/updated", + "type": "string" + }, + "params": { + "$ref": "#/$defs/TurnPlanUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/SkillsChangedNotification.json b/lime-rs/crates/app-server-protocol/schema/json/v2/SkillsChangedNotification.json new file mode 100644 index 000000000..2d7be8b55 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/SkillsChangedNotification.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Signals that the process-wide Skill catalog must be read again.", + "title": "SkillsChangedNotification", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/Thread.json b/lime-rs/crates/app-server-protocol/schema/json/v2/Thread.json index bab3acf35..a01da49cf 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/Thread.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/Thread.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -441,6 +574,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1261,6 +1401,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1268,7 +1409,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 1f1354ad0..26818dca5 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -465,6 +598,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1426,6 +1566,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1433,7 +1574,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadListResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadListResponse.json index bdee83c77..0dea5e89f 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -441,6 +574,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1402,6 +1542,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1409,7 +1550,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 0dd42c3d4..d99936ed9 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -441,6 +574,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1402,6 +1542,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1409,7 +1550,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8a4ebd41a..339e79de3 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -441,6 +574,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1402,6 +1542,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1409,7 +1550,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index b136938cf..c5ce09fe2 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -465,6 +598,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1426,6 +1566,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1433,7 +1574,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadSearchResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadSearchResponse.json index 53ba6f372..538eb4568 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadSearchResponse.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadSearchResponse.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -441,6 +574,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1417,6 +1557,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1424,7 +1565,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadSearchResult.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadSearchResult.json index 468adc57b..ac76324d4 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadSearchResult.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadSearchResult.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -441,6 +574,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1402,6 +1542,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1409,7 +1550,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadStartResponse.json index ef7866e64..fdfe61590 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -465,6 +598,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1426,6 +1566,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1433,7 +1574,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index d10eb425a..ef8aaa35a 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -441,6 +574,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1402,6 +1542,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1409,7 +1550,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index bfec5026b..0ff7d76fa 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -418,6 +551,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1164,6 +1304,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1171,7 +1312,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 4488c9b7d..b362e4f2e 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -441,6 +574,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1402,6 +1542,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1409,7 +1550,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/Turn.json b/lime-rs/crates/app-server-protocol/schema/json/v2/Turn.json index 85a28b874..9d59ca2a0 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/Turn.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/Turn.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -418,6 +551,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1107,6 +1247,7 @@ ] }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1114,7 +1255,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index f2ab97fb1..c6fb15df1 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -418,6 +551,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1164,6 +1304,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1171,7 +1312,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnError.json b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnError.json index a7a91dc90..3637288f9 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnError.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnError.json @@ -1,5 +1,148 @@ { + "$defs": { + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + } + }, "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -7,7 +150,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnPlanStep.json b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnPlanStep.json new file mode 100644 index 000000000..7501767cd --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnPlanStep.json @@ -0,0 +1,28 @@ +{ + "$defs": { + "TurnPlanStepStatus": { + "enum": [ + "completed", + "inProgress", + "pending" + ], + "type": "string" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/$defs/TurnPlanStepStatus" + }, + "step": { + "type": "string" + } + }, + "required": [ + "status", + "step" + ], + "title": "TurnPlanStep", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnPlanStepStatus.json b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnPlanStepStatus.json new file mode 100644 index 000000000..84b7bd3e6 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnPlanStepStatus.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "enum": [ + "completed", + "inProgress", + "pending" + ], + "title": "TurnPlanStepStatus", + "type": "string" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnPlanUpdatedNotification.json b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnPlanUpdatedNotification.json new file mode 100644 index 000000000..1f1bb7614 --- /dev/null +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnPlanUpdatedNotification.json @@ -0,0 +1,59 @@ +{ + "$defs": { + "TurnPlanStep": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/$defs/TurnPlanStepStatus" + }, + "step": { + "type": "string" + } + }, + "required": [ + "status", + "step" + ], + "type": "object" + }, + "TurnPlanStepStatus": { + "enum": [ + "completed", + "inProgress", + "pending" + ], + "type": "string" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Reports the latest turn-scoped execution checklist.", + "properties": { + "explanation": { + "default": null, + "type": [ + "null", + "string" + ] + }, + "plan": { + "items": { + "$ref": "#/$defs/TurnPlanStep" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "plan", + "threadId", + "turnId" + ], + "title": "TurnPlanUpdatedNotification", + "type": "object" +} diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnStartResponse.json b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnStartResponse.json index bc4e7d02b..286def938 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -418,6 +551,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1164,6 +1304,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1171,7 +1312,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 74ecc88c4..6d77e2086 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -418,6 +551,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1164,6 +1304,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1171,7 +1312,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnsPage.json b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnsPage.json index 059d19e24..d46b90dec 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/TurnsPage.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/TurnsPage.json @@ -21,6 +21,139 @@ ], "type": "object" }, + "CodexErrorInfo": { + "description": "Codex-compatible error classification exposed on the v2 wire.", + "oneOf": [ + { + "enum": [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/$defs/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "type": "object" + } + ] + }, "CollabAgentState": { "properties": { "message": { @@ -418,6 +551,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "compact", + "review" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "completed", @@ -1164,6 +1304,7 @@ "type": "object" }, "TurnError": { + "additionalProperties": false, "properties": { "additionalDetails": { "type": [ @@ -1171,7 +1312,16 @@ "string" ] }, - "errorInfo": true, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/$defs/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, "message": { "type": "string" } diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/common.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/common.rs index 9e615b0e8..ffd489041 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/common.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/common.rs @@ -262,12 +262,56 @@ pub struct Turn { pub duration_ms: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum NonSteerableTurnKind { + Review, + Compact, +} + +/// Codex-compatible error classification exposed on the v2 wire. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] +pub enum CodexErrorInfo { + ContextWindowExceeded, + SessionBudgetExceeded, + UsageLimitExceeded, + ServerOverloaded, + CyberPolicy, + HttpConnectionFailed { + #[serde(rename = "httpStatusCode")] + http_status_code: Option, + }, + ResponseStreamConnectionFailed { + #[serde(rename = "httpStatusCode")] + http_status_code: Option, + }, + InternalServerError, + Unauthorized, + BadRequest, + ThreadRollbackFailed, + SandboxError, + ResponseStreamDisconnected { + #[serde(rename = "httpStatusCode")] + http_status_code: Option, + }, + ResponseTooManyFailedAttempts { + #[serde(rename = "httpStatusCode")] + http_status_code: Option, + }, + ActiveTurnNotSteerable { + #[serde(rename = "turnKind")] + turn_kind: NonSteerableTurnKind, + }, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct TurnError { pub message: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_info: Option, + pub codex_error_info: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub additional_details: Option, } diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs index fdac5cf17..72f28372e 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs @@ -1,14 +1,15 @@ use super::{ AgentMessageDeltaNotification, ArtifactWriteParams, ArtifactWriteResponse, CommandExecutionOutputDeltaNotification, CommandExecutionRequestApprovalParams, - ConfigWarningNotification, CurrentTimeReadParams, DynamicToolCallParams, + ConfigWarningNotification, CurrentTimeReadParams, DynamicToolCallParams, ErrorNotification, FileChangePatchUpdatedNotification, FileChangeRequestApprovalParams, ItemCompletedNotification, - ItemStartedNotification, McpServerElicitationRequestParams, McpToolCallProgressNotification, - MediaReadParams, MediaReadResponse, Method, ModelListParams, ModelListUpdatedNotification, + ItemStartedNotification, McpServerElicitationRequestParams, + McpServerOauthLoginCompletedNotification, McpToolCallProgressNotification, MediaReadParams, + MediaReadResponse, Method, ModelListParams, ModelListUpdatedNotification, ModelReroutedNotification, ModelSafetyBufferingUpdatedNotification, ModelVerificationNotification, PermissionsRequestApprovalParams, PlanDeltaNotification, ReasoningSummaryPartAddedNotification, ReasoningSummaryTextDeltaNotification, - ReasoningTextDeltaNotification, ServerRequestResolvedNotification, + ReasoningTextDeltaNotification, ServerRequestResolvedNotification, SkillsChangedNotification, ThreadApproveGuardianDeniedActionParams, ThreadApproveGuardianDeniedActionResponse, ThreadArchiveParams, ThreadArchiveResponse, ThreadArchivedNotification, ThreadBackgroundTerminalsCleanParams, ThreadBackgroundTerminalsCleanResponse, @@ -33,19 +34,21 @@ use super::{ ThreadTokenUsageUpdatedNotification, ThreadTurnsListParams, ThreadTurnsListResponse, ThreadUnarchiveParams, ThreadUnarchiveResponse, ThreadUnarchivedNotification, ThreadUnsubscribeParams, ThreadUnsubscribeResponse, ToolRequestUserInputParams, - TurnCompletedNotification, TurnInterruptParams, TurnInterruptResponse, TurnStartParams, - TurnStartResponse, TurnStartedNotification, TurnSteerParams, TurnSteerResponse, - WarningNotification, METHOD_COMMAND_EXECUTION_OUTPUT_DELTA, METHOD_CONFIG_WARNING, - METHOD_CURRENT_TIME_READ, 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, + 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_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_THREAD_CLOSED, - METHOD_THREAD_GOAL_CLEARED, METHOD_THREAD_GOAL_UPDATED, METHOD_THREAD_NAME_UPDATED, - METHOD_THREAD_STATUS_CHANGED, METHOD_THREAD_TOKEN_USAGE_UPDATED, METHOD_WARNING, + 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; @@ -645,6 +648,12 @@ pub enum ServerNotification { ConfigWarning(ConfigWarningNotification), #[serde(rename = "warning")] Warning(WarningNotification), + #[serde(rename = "error")] + Error(ErrorNotification), + #[serde(rename = "skills/changed")] + SkillsChanged(SkillsChangedNotification), + #[serde(rename = "mcpServer/oauthLogin/completed")] + McpServerOauthLoginCompleted(McpServerOauthLoginCompletedNotification), #[serde(rename = "thread/started")] ThreadStarted(ThreadStartedNotification), #[serde(rename = "thread/archived")] @@ -663,6 +672,8 @@ pub enum ServerNotification { TurnStarted(TurnStartedNotification), #[serde(rename = "turn/completed")] TurnCompleted(TurnCompletedNotification), + #[serde(rename = "turn/plan/updated")] + TurnPlanUpdated(TurnPlanUpdatedNotification), #[serde(rename = "item/started")] ItemStarted(ItemStartedNotification), #[serde(rename = "item/completed")] @@ -708,6 +719,9 @@ impl ServerNotification { match self { Self::ConfigWarning(_) => METHOD_CONFIG_WARNING, Self::Warning(_) => METHOD_WARNING, + Self::Error(_) => METHOD_ERROR, + Self::SkillsChanged(_) => METHOD_SKILLS_CHANGED, + Self::McpServerOauthLoginCompleted(_) => METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED, Self::ThreadStarted(_) => "thread/started", Self::ThreadArchived(_) => "thread/archived", Self::ThreadDeleted(_) => "thread/deleted", @@ -717,6 +731,7 @@ impl ServerNotification { Self::ThreadStatusChanged(_) => METHOD_THREAD_STATUS_CHANGED, Self::TurnStarted(_) => "turn/started", Self::TurnCompleted(_) => "turn/completed", + Self::TurnPlanUpdated(_) => METHOD_TURN_PLAN_UPDATED, Self::ItemStarted(_) => "item/started", Self::ItemCompleted(_) => "item/completed", Self::AgentMessageDelta(_) => "item/agentMessage/delta", @@ -743,7 +758,7 @@ impl ServerNotification { impl TryFrom for ServerNotification { type Error = String; - fn try_from(notification: JsonRpcNotification) -> Result { + fn try_from(notification: JsonRpcNotification) -> Result { let params = notification.params.unwrap_or_else(|| serde_json::json!({})); match notification.method.as_str() { METHOD_CONFIG_WARNING => serde_json::from_value(params) @@ -752,6 +767,15 @@ impl TryFrom for ServerNotification { METHOD_WARNING => serde_json::from_value(params) .map(Self::Warning) .map_err(|error| error.to_string()), + METHOD_ERROR => serde_json::from_value(params) + .map(Self::Error) + .map_err(|error| error.to_string()), + METHOD_SKILLS_CHANGED => serde_json::from_value(params) + .map(Self::SkillsChanged) + .map_err(|error| error.to_string()), + METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED => serde_json::from_value(params) + .map(Self::McpServerOauthLoginCompleted) + .map_err(|error| error.to_string()), "thread/started" => serde_json::from_value(params) .map(Self::ThreadStarted) .map_err(|error| error.to_string()), @@ -779,6 +803,9 @@ impl TryFrom for ServerNotification { "turn/completed" => serde_json::from_value(params) .map(Self::TurnCompleted) .map_err(|error| error.to_string()), + METHOD_TURN_PLAN_UPDATED => serde_json::from_value(params) + .map(Self::TurnPlanUpdated) + .map_err(|error| error.to_string()), "item/started" => serde_json::from_value(params) .map(Self::ItemStarted) .map_err(|error| error.to_string()), @@ -848,6 +875,13 @@ impl From for JsonRpcNotification { jsonrpc_notification(METHOD_CONFIG_WARNING, params) } ServerNotification::Warning(params) => jsonrpc_notification(METHOD_WARNING, params), + ServerNotification::Error(params) => jsonrpc_notification(METHOD_ERROR, params), + ServerNotification::SkillsChanged(params) => { + jsonrpc_notification(METHOD_SKILLS_CHANGED, params) + } + ServerNotification::McpServerOauthLoginCompleted(params) => { + jsonrpc_notification(METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED, params) + } ServerNotification::ThreadStarted(params) => { jsonrpc_notification("thread/started", params) } @@ -873,6 +907,9 @@ impl From for JsonRpcNotification { ServerNotification::TurnCompleted(params) => { jsonrpc_notification("turn/completed", params) } + ServerNotification::TurnPlanUpdated(params) => { + jsonrpc_notification(METHOD_TURN_PLAN_UPDATED, params) + } ServerNotification::ItemStarted(params) => jsonrpc_notification("item/started", params), ServerNotification::ItemCompleted(params) => { jsonrpc_notification("item/completed", params) diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/mcp.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/mcp.rs index 980a987c5..2d0b9d786 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/mcp.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/mcp.rs @@ -28,6 +28,21 @@ pub struct McpToolCallProgressNotification { pub message: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct McpServerOauthLoginCompletedNotification { + pub name: String, + #[schemars(schema_with = "nullable_string_schema")] + pub thread_id: Option, + pub success: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +fn nullable_string_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + generator.subschema_for::>() +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct McpServerElicitationRequestParams { @@ -238,4 +253,47 @@ mod tests { .is_err() ); } + + #[test] + fn oauth_login_completed_matches_codex_wire_and_fails_closed() { + let success = McpServerOauthLoginCompletedNotification { + name: "remote-docs".to_string(), + thread_id: None, + success: true, + error: None, + }; + assert_eq!( + serde_json::to_value(success).expect("serialize OAuth completion"), + json!({ + "name": "remote-docs", + "threadId": null, + "success": true + }) + ); + + assert!( + serde_json::from_value::(json!({ + "name": "remote-docs", + "threadId": null, + "success": false, + "error": "scope rejected", + "serverName": "legacy-name" + })) + .is_err() + ); + + let schema = serde_json::to_value(schemars::schema_for!( + McpServerOauthLoginCompletedNotification + )) + .expect("serialize OAuth completion schema"); + let required = schema["required"] + .as_array() + .expect("OAuth completion schema required fields"); + assert!(required.iter().any(|field| field == "threadId")); + let thread_id_types = schema["properties"]["threadId"]["type"] + .as_array() + .expect("OAuth completion threadId must be nullable"); + assert!(thread_id_types.iter().any(|value| value == "null")); + assert!(thread_id_types.iter().any(|value| value == "string")); + } } diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs index dead7077f..0bc63f131 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs @@ -48,6 +48,7 @@ pub const METHOD_THREAD_NAME_UPDATED: &str = "thread/name/updated"; pub const METHOD_THREAD_STATUS_CHANGED: &str = "thread/status/changed"; pub const METHOD_TURN_STARTED: &str = "turn/started"; pub const METHOD_TURN_COMPLETED: &str = "turn/completed"; +pub const METHOD_TURN_PLAN_UPDATED: &str = "turn/plan/updated"; 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"; @@ -68,6 +69,9 @@ pub const METHOD_THREAD_GOAL_CLEARED: &str = "thread/goal/cleared"; pub const METHOD_SERVER_REQUEST_RESOLVED: &str = "serverRequest/resolved"; pub const METHOD_CONFIG_WARNING: &str = "configWarning"; 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_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 = @@ -287,6 +291,9 @@ pub const METHODS: &[&str] = &[ pub const NOTIFICATION_METHODS: &[&str] = &[ METHOD_CONFIG_WARNING, METHOD_WARNING, + METHOD_ERROR, + METHOD_SKILLS_CHANGED, + METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED, METHOD_THREAD_STARTED, METHOD_THREAD_ARCHIVED, METHOD_THREAD_DELETED, @@ -296,6 +303,7 @@ pub const NOTIFICATION_METHODS: &[&str] = &[ METHOD_THREAD_STATUS_CHANGED, METHOD_TURN_STARTED, METHOD_TURN_COMPLETED, + METHOD_TURN_PLAN_UPDATED, METHOD_ITEM_STARTED, METHOD_ITEM_COMPLETED, METHOD_AGENT_MESSAGE_DELTA, diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/notification.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/notification.rs index 11956e952..83af7bb4e 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/notification.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/notification.rs @@ -1,7 +1,50 @@ +use super::TurnError; use crate::RequestId; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// Signals that the process-wide Skill catalog must be read again. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SkillsChangedNotification {} + +/// Reports a transient retry or terminal error for the current turn. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ErrorNotification { + pub error: TurnError, + /// True only when App Server has already decided to retry this turn. + pub will_retry: bool, + pub thread_id: String, + pub turn_id: String, +} + +/// Reports the latest turn-scoped execution checklist. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TurnPlanUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + #[serde(default)] + pub explanation: Option, + pub plan: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TurnPlanStep { + pub step: String, + pub status: TurnPlanStepStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum TurnPlanStepStatus { + Pending, + InProgress, + Completed, +} + /// A user-visible runtime warning projected from the current event stream. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/schema_types.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/schema_types.rs index 43c449eae..cf546d8ab 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/schema_types.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/schema_types.rs @@ -7,6 +7,9 @@ pub const V2_SCHEMA_TYPE_NAMES: &[&str] = &[ "TextRange", "ConfigWarningNotification", "WarningNotification", + "ErrorNotification", + "SkillsChangedNotification", + "McpServerOauthLoginCompletedNotification", "CurrentTimeReadParams", "CurrentTimeReadResponse", "AdditionalNetworkPermissions", @@ -72,6 +75,8 @@ pub const V2_SCHEMA_TYPE_NAMES: &[&str] = &[ "Thread", "GitInfo", "Turn", + "CodexErrorInfo", + "NonSteerableTurnKind", "TurnError", "ThreadItem", "HookPromptFragment", @@ -197,6 +202,9 @@ pub const V2_SCHEMA_TYPE_NAMES: &[&str] = &[ "ThreadClosedNotification", "TurnStartedNotification", "TurnCompletedNotification", + "TurnPlanUpdatedNotification", + "TurnPlanStep", + "TurnPlanStepStatus", "ItemStartedNotification", "ItemCompletedNotification", "AgentMessageDeltaNotification", diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/tests.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/tests.rs index c699c83b9..ecfe8977e 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/tests.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/tests.rs @@ -550,6 +550,121 @@ fn warning_notification_round_trips_thread_scoped_lime_shape() { .is_err()); } +#[test] +fn error_notification_round_trips_exact_codex_shape() { + let expected = json!({ + "method": "error", + "params": { + "error": { + "message": "provider stream timed out", + "codexErrorInfo": { + "responseStreamDisconnected": { + "httpStatusCode": null + } + }, + "additionalDetails": "request id: req-1" + }, + "willRetry": false, + "threadId": "thread-1", + "turnId": "turn-1" + } + }); + + let notification: ServerNotification = + serde_json::from_value(expected.clone()).expect("decode error notification"); + assert_eq!(notification.method(), METHOD_ERROR); + let raw: crate::JsonRpcNotification = notification.clone().into(); + assert_eq!(raw.method, METHOD_ERROR); + assert_eq!( + ServerNotification::try_from(raw).expect("decode JSON-RPC error notification"), + notification + ); + assert_eq!( + serde_json::to_value(notification).expect("encode error notification"), + expected + ); + assert!(NOTIFICATION_METHODS.contains(&METHOD_ERROR)); + + for malformed in [ + json!({ + "method": "error", + "params": { + "error": { "message": "failed" }, + "threadId": "thread-1", + "turnId": "turn-1" + } + }), + json!({ + "method": "error", + "params": { + "error": { "message": "failed" }, + "willRetry": "false", + "threadId": "thread-1", + "turnId": "turn-1" + } + }), + json!({ + "method": "error", + "params": { + "error": { "message": "failed", "errorInfo": "legacy" }, + "willRetry": false, + "threadId": "thread-1", + "turnId": "turn-1" + } + }), + json!({ + "method": "error", + "params": { + "error": { "message": "failed", "additionalDetails": 42 }, + "willRetry": false, + "threadId": "thread-1", + "turnId": "turn-1" + } + }), + json!({ + "method": "error", + "params": { + "error": { "message": "failed" }, + "willRetry": false, + "threadId": "thread-1", + "turnId": "turn-1", + "retryable": true + } + }), + ] { + assert!(serde_json::from_value::(malformed).is_err()); + } +} + +#[test] +fn skills_changed_notification_round_trips_empty_invalidation_shape() { + let expected = json!({ + "method": "skills/changed", + "params": {} + }); + + let notification: ServerNotification = + serde_json::from_value(expected.clone()).expect("decode skills changed notification"); + assert_eq!(notification.method(), METHOD_SKILLS_CHANGED); + let raw: crate::JsonRpcNotification = notification.clone().into(); + assert_eq!(raw.method, METHOD_SKILLS_CHANGED); + assert_eq!(raw.params, Some(json!({}))); + assert_eq!( + ServerNotification::try_from(raw).expect("decode JSON-RPC skills changed notification"), + notification + ); + assert_eq!( + serde_json::to_value(notification).expect("encode skills changed notification"), + expected + ); + assert!(NOTIFICATION_METHODS.contains(&METHOD_SKILLS_CHANGED)); + assert!(serde_json::from_value::(json!({ + "method": "skills/changed", + "params": { "path": "/private/skill" } + })) + .is_err()); +} + #[test] fn thread_search_occurrences_round_trips_exact_codex_shape() { let expected = json!({ @@ -1113,6 +1228,41 @@ fn plan_delta_notification_round_trips_codex_shape() { assert!(NOTIFICATION_METHODS.contains(&METHOD_PLAN_DELTA)); } +#[test] +fn turn_plan_updated_notification_round_trips_codex_shape_and_rejects_unknown_fields() { + let expected = json!({ + "method": "turn/plan/updated", + "params": { + "threadId": "thread_1", + "turnId": "turn_2", + "explanation": "继续执行", + "plan": [ + { "step": "读取现状", "status": "completed" }, + { "step": "补齐主链", "status": "inProgress" } + ] + } + }); + + let notification: ServerNotification = + serde_json::from_value(expected.clone()).expect("decode turn plan update"); + assert_eq!(notification.method(), METHOD_TURN_PLAN_UPDATED); + let raw: crate::JsonRpcNotification = notification.clone().into(); + assert_eq!(raw.method, METHOD_TURN_PLAN_UPDATED); + assert_eq!( + ServerNotification::try_from(raw).expect("decode JSON-RPC turn plan update"), + notification + ); + assert_eq!( + serde_json::to_value(notification).expect("encode turn plan update"), + expected + ); + assert!(NOTIFICATION_METHODS.contains(&METHOD_TURN_PLAN_UPDATED)); + + let mut malformed = expected; + malformed["params"]["plan"][0]["unexpected"] = json!(true); + assert!(serde_json::from_value::(malformed).is_err()); +} + #[test] fn command_execution_output_delta_notification_round_trips_codex_shape() { let expected = json!({ @@ -2119,6 +2269,9 @@ fn typed_v2_envelope_schema_names_are_stable() { [ "configWarning", "warning", + "error", + "skills/changed", + "mcpServer/oauthLogin/completed", "thread/started", "thread/archived", "thread/deleted", @@ -2128,6 +2281,7 @@ fn typed_v2_envelope_schema_names_are_stable() { "thread/status/changed", "turn/started", "turn/completed", + "turn/plan/updated", "item/started", "item/completed", "item/agentMessage/delta", diff --git a/lime-rs/crates/app-server-protocol/src/schema_export/registry.rs b/lime-rs/crates/app-server-protocol/src/schema_export/registry.rs index 61a5f05db..fd6a7370b 100644 --- a/lime-rs/crates/app-server-protocol/src/schema_export/registry.rs +++ b/lime-rs/crates/app-server-protocol/src/schema_export/registry.rs @@ -748,6 +748,11 @@ pub(super) fn v2_schemas() -> Vec { typed_schema::("TextRange"), typed_schema::("ConfigWarningNotification"), typed_schema::("WarningNotification"), + typed_schema::("ErrorNotification"), + typed_schema::("SkillsChangedNotification"), + typed_schema::( + "McpServerOauthLoginCompletedNotification", + ), typed_schema::("CurrentTimeReadParams"), typed_schema::("CurrentTimeReadResponse"), typed_schema::("AdditionalNetworkPermissions"), @@ -823,6 +828,8 @@ pub(super) fn v2_schemas() -> Vec { typed_schema::("Thread"), typed_schema::("GitInfo"), typed_schema::("Turn"), + typed_schema::("CodexErrorInfo"), + typed_schema::("NonSteerableTurnKind"), typed_schema::("TurnError"), typed_schema::("ThreadItem"), typed_schema::("HookPromptFragment"), @@ -972,6 +979,9 @@ pub(super) fn v2_schemas() -> Vec { typed_schema::("ThreadClosedNotification"), typed_schema::("TurnStartedNotification"), typed_schema::("TurnCompletedNotification"), + typed_schema::("TurnPlanUpdatedNotification"), + typed_schema::("TurnPlanStep"), + typed_schema::("TurnPlanStepStatus"), typed_schema::("ItemStartedNotification"), typed_schema::("ItemCompletedNotification"), typed_schema::("AgentMessageDeltaNotification"), diff --git a/lime-rs/crates/app-server/Cargo.toml b/lime-rs/crates/app-server/Cargo.toml index e6ab55042..14b56c680 100644 --- a/lime-rs/crates/app-server/Cargo.toml +++ b/lime-rs/crates/app-server/Cargo.toml @@ -37,6 +37,7 @@ lime-browser-runtime.workspace = true lime-media-runtime.workspace = true lime-mcp.workspace = true model-provider.workspace = true +notify.workspace = true runtime-core.workspace = true lime-skills.workspace = true lime-services = { workspace = true, features = ["local-sensevoice"] } diff --git a/lime-rs/crates/app-server/src/lib.rs b/lime-rs/crates/app-server/src/lib.rs index a57c7ec90..53e87bd60 100644 --- a/lime-rs/crates/app-server/src/lib.rs +++ b/lime-rs/crates/app-server/src/lib.rs @@ -35,6 +35,7 @@ mod runtime_backend; mod runtime_factory; mod server_request; mod skill_registry; +mod skills_watcher; mod thread_listener; #[cfg(test)] mod thread_listener_tests; @@ -295,6 +296,7 @@ pub struct AppServer { server_requests: server_request::ServerRequestRouter, current_time_requests: current_time::CurrentTimeRequestRouter, mcp_elicitation_requests: mcp_elicitation::ElicitationRequestSource, + _skills_watcher: Option>, } #[derive(Clone)] @@ -345,6 +347,7 @@ impl AppServer { }; let interrupt_router = server_requests.clone(); let notification_bridge = interrupt_bridge.clone(); + let skills_watcher_bridge = notification_bridge.clone(); let turn_interrupt_hook: processor::TurnInterruptHook = Arc::new(move |thread_id, turn_id| { let bridge = interrupt_bridge.clone(); @@ -364,6 +367,11 @@ impl AppServer { .await; }) }); + let skills_watcher = if cfg!(test) { + None + } else { + skills_watcher::SkillsWatcher::start_default(skills_watcher_bridge) + }; Self { processor: RequestProcessor::new_with_thread_states(runtime, thread_states.clone()) .with_turn_interrupt_hook(turn_interrupt_hook) @@ -380,6 +388,7 @@ impl AppServer { server_requests, current_time_requests, mcp_elicitation_requests: mcp_elicitation::ElicitationRequestSource::default(), + _skills_watcher: skills_watcher, } } diff --git a/lime-rs/crates/app-server/src/local_data_source/impls/mcp.rs b/lime-rs/crates/app-server/src/local_data_source/impls/mcp.rs index bd6f08351..5e212a7db 100644 --- a/lime-rs/crates/app-server/src/local_data_source/impls/mcp.rs +++ b/lime-rs/crates/app-server/src/local_data_source/impls/mcp.rs @@ -1,5 +1,5 @@ use super::super::*; -use app_server_protocol::{McpServerOauthLoginParams, McpServerOauthLoginResponse}; +use app_server_protocol::McpServerOauthLoginParams; use async_trait::async_trait; #[async_trait] @@ -76,7 +76,7 @@ impl McpAppDataSource for LocalAppDataSource { async fn login_mcp_server_oauth( &self, params: McpServerOauthLoginParams, - ) -> Result { + ) -> Result { mcp::login_mcp_server_oauth(&self.db, &self.mcp_manager, params).await } diff --git a/lime-rs/crates/app-server/src/local_data_source/mcp.rs b/lime-rs/crates/app-server/src/local_data_source/mcp.rs index d90b69c94..aac0ef5b7 100644 --- a/lime-rs/crates/app-server/src/local_data_source/mcp.rs +++ b/lime-rs/crates/app-server/src/local_data_source/mcp.rs @@ -20,7 +20,6 @@ use app_server_protocol::McpServerImportFromAppResponse; use app_server_protocol::McpServerLifecycleResponse; use app_server_protocol::McpServerListResponse; use app_server_protocol::McpServerOauthLoginParams; -use app_server_protocol::McpServerOauthLoginResponse; use app_server_protocol::McpServerStartParams; use app_server_protocol::McpServerStatusListResponse; use app_server_protocol::McpServerStopParams; @@ -165,7 +164,7 @@ pub(crate) async fn login_mcp_server_oauth( db: &DbConnection, manager: &McpManagerState, params: McpServerOauthLoginParams, -) -> Result { +) -> Result { let server = McpService::get_all(db) .map_err(data_error)? .into_iter() @@ -184,10 +183,6 @@ pub(crate) async fn login_mcp_server_oauth( ) .await .map_err(mcp_error) - .map(|response| McpServerOauthLoginResponse { - authorization_url: response.authorization_url, - state: response.state, - }) } pub(crate) async fn list_mcp_tools( diff --git a/lime-rs/crates/app-server/src/local_data_source/tests.rs b/lime-rs/crates/app-server/src/local_data_source/tests.rs index 6f3aa76d3..47fa99f13 100644 --- a/lime-rs/crates/app-server/src/local_data_source/tests.rs +++ b/lime-rs/crates/app-server/src/local_data_source/tests.rs @@ -18,6 +18,7 @@ use app_server_protocol::METHOD_MCP_SERVER_STATUS_LIST; use app_server_protocol::METHOD_MCP_SERVER_STOP; use app_server_protocol::METHOD_MCP_TOOL_CALL; use app_server_protocol::METHOD_MCP_TOOL_LIST; +use app_server_protocol::METHOD_SKILL_CACHE_REFRESH; use app_server_protocol::SERVER_NAME; use app_server_transport::decode_message; use lime_core::database::schema::create_tables; @@ -82,6 +83,55 @@ fn setup_data_source() -> LocalAppDataSource { } } +#[tokio::test] +async fn successful_skill_catalog_mutation_emits_typed_invalidation() { + let server = AppServer::with_runtime( + RuntimeCore::with_backend(Arc::new(MockBackend)) + .with_app_data_source(Arc::new(setup_data_source())), + ); + let init = app_server_request( + &server, + 1, + METHOD_INITIALIZE, + json!({ + "clientInfo": { "name": "skills-changed-test", "version": "test" }, + "capabilities": {} + }), + ) + .await; + assert_eq!( + init.pointer("/result/serverInfo/name"), + Some(&json!(SERVER_NAME)) + ); + app_server_notification(&server, METHOD_INITIALIZED, json!({})).await; + + let line = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": METHOD_SKILL_CACHE_REFRESH, + "params": {} + }) + .to_string(); + let responses = server + .handle_json_line(&line) + .await + .expect("handle Skill catalog mutation"); + + assert_eq!(responses.len(), 2, "{responses:?}"); + let response: Value = serde_json::from_str(&responses[0]).expect("decode response"); + let notification = decode_message(&responses[1]).expect("decode notification"); + assert_eq!(response["result"], json!({ "success": true })); + assert_eq!( + notification, + app_server_protocol::JsonRpcMessage::Notification( + app_server_protocol::protocol::v2::ServerNotification::SkillsChanged( + app_server_protocol::protocol::v2::SkillsChangedNotification {}, + ) + .into(), + ) + ); +} + #[tokio::test] async fn diagnostics_use_injected_agent_log_root() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/lime-rs/crates/app-server/src/processor/mcp.rs b/lime-rs/crates/app-server/src/processor/mcp.rs index ae20ae39f..57cb52aab 100644 --- a/lime-rs/crates/app-server/src/processor/mcp.rs +++ b/lime-rs/crates/app-server/src/processor/mcp.rs @@ -1,12 +1,16 @@ //! mcp domain handlers for the App Server processor. use super::{dispatch_result, parse_params, to_jsonrpc_error, RequestProcessor, RpcDispatch}; +use app_server_protocol::protocol::v2::{ + McpServerOauthLoginCompletedNotification, ServerNotification, +}; use app_server_protocol::{ JsonRpcError, McpPromptGetParams, McpResourceReadParams, McpResourceSubscribeParams, McpResourceUnsubscribeParams, McpServerCreateParams, McpServerDeleteParams, McpServerEnabledSetParams, McpServerImportFromAppParams, McpServerOauthLoginParams, - McpServerStartParams, McpServerStopParams, McpServerUpdateParams, McpToolCallParams, - McpToolCallWithCallerParams, McpToolListForContextParams, McpToolSearchParams, + McpServerOauthLoginResponse, McpServerStartParams, McpServerStopParams, McpServerUpdateParams, + McpToolCallParams, McpToolCallWithCallerParams, McpToolListForContextParams, + McpToolSearchParams, }; impl RequestProcessor { @@ -148,11 +152,33 @@ impl RequestProcessor { ) -> Result { self.ensure_initialized()?; let params: McpServerOauthLoginParams = parse_params(params)?; - let response = self + let server_name = params.name.clone(); + let handle = self .runtime .login_mcp_server_oauth(params) .await .map_err(to_jsonrpc_error)?; + let response = McpServerOauthLoginResponse { + authorization_url: handle.authorization_url.clone(), + state: handle.state.clone(), + }; + let processor = self.clone(); + tokio::spawn(async move { + let (success, error) = match handle.wait().await { + Ok(()) => (true, None), + Err(error) => (false, Some(error.to_string())), + }; + processor + .publish_server_notification(ServerNotification::McpServerOauthLoginCompleted( + McpServerOauthLoginCompletedNotification { + name: server_name, + thread_id: None, + success, + error, + }, + )) + .await; + }); dispatch_result(response) } diff --git a/lime-rs/crates/app-server/src/processor/skill.rs b/lime-rs/crates/app-server/src/processor/skill.rs index 67933d7b1..a3a9e0356 100644 --- a/lime-rs/crates/app-server/src/processor/skill.rs +++ b/lime-rs/crates/app-server/src/processor/skill.rs @@ -1,6 +1,9 @@ //! skill domain handlers for the App Server processor. use super::{dispatch_result, parse_params, to_jsonrpc_error, RequestProcessor, RpcDispatch}; +use app_server_protocol::protocol::v2::{ + ServerNotification as V2ServerNotification, SkillsChangedNotification, +}; use app_server_protocol::{ JsonRpcError, SkillDownloadInstallParams, SkillLocalDetailInspectParams, SkillLocalImportParams, SkillLocalInspectParams, SkillLocalRenameParams, @@ -10,6 +13,7 @@ use app_server_protocol::{ SkillRemoteInspectParams, SkillRepositoryDeleteParams, SkillRepositorySaveParams, SkillScaffoldCreateParams, }; +use serde::Serialize; impl RequestProcessor { pub(super) async fn handle_skill_list_impl(&self) -> Result { @@ -52,12 +56,13 @@ impl RequestProcessor { ) -> Result { self.ensure_initialized()?; let params: SkillManagementInstallParams = parse_params(params)?; + let changes_lime_catalog = is_lime_app(¶ms.app); let response = self .runtime .install_management_skill(params) .await .map_err(to_jsonrpc_error)?; - dispatch_result(response) + dispatch_skill_catalog_mutation(response, changes_lime_catalog) } pub(super) async fn handle_skill_management_uninstall_impl( @@ -66,12 +71,13 @@ impl RequestProcessor { ) -> Result { self.ensure_initialized()?; let params: SkillManagementUninstallParams = parse_params(params)?; + let changes_lime_catalog = is_lime_app(¶ms.app); let response = self .runtime .uninstall_management_skill(params) .await .map_err(to_jsonrpc_error)?; - dispatch_result(response) + dispatch_skill_catalog_mutation(response, changes_lime_catalog) } pub(super) async fn handle_skill_repository_list_impl( @@ -97,7 +103,7 @@ impl RequestProcessor { .save_skill_repository(params) .await .map_err(to_jsonrpc_error)?; - dispatch_result(response) + dispatch_skill_catalog_mutation(response, true) } pub(super) async fn handle_skill_repository_delete_impl( @@ -111,7 +117,7 @@ impl RequestProcessor { .delete_skill_repository(params) .await .map_err(to_jsonrpc_error)?; - dispatch_result(response) + dispatch_skill_catalog_mutation(response, true) } pub(super) async fn handle_skill_cache_refresh_impl( @@ -123,7 +129,7 @@ impl RequestProcessor { .refresh_skill_cache() .await .map_err(to_jsonrpc_error)?; - dispatch_result(response) + dispatch_skill_catalog_mutation(response, true) } pub(super) async fn handle_skill_installed_directories_list_impl( @@ -186,12 +192,13 @@ impl RequestProcessor { ) -> Result { self.ensure_initialized()?; let params: SkillScaffoldCreateParams = parse_params(params)?; + let changes_lime_catalog = is_lime_app(¶ms.app); let response = self .runtime .create_skill_scaffold(params) .await .map_err(to_jsonrpc_error)?; - dispatch_result(response) + dispatch_skill_catalog_mutation(response, changes_lime_catalog) } pub(super) async fn handle_skill_local_import_impl( @@ -200,12 +207,13 @@ impl RequestProcessor { ) -> Result { self.ensure_initialized()?; let params: SkillLocalImportParams = parse_params(params)?; + let changes_lime_catalog = is_lime_app(¶ms.app); let response = self .runtime .import_local_skill(params) .await .map_err(to_jsonrpc_error)?; - dispatch_result(response) + dispatch_skill_catalog_mutation(response, changes_lime_catalog) } pub(super) async fn handle_skill_local_rename_impl( @@ -214,12 +222,13 @@ impl RequestProcessor { ) -> Result { self.ensure_initialized()?; let params: SkillLocalRenameParams = parse_params(params)?; + let changes_lime_catalog = is_lime_app(¶ms.app); let response = self .runtime .rename_local_skill(params) .await .map_err(to_jsonrpc_error)?; - dispatch_result(response) + dispatch_skill_catalog_mutation(response, changes_lime_catalog) } pub(super) async fn handle_skill_remote_inspect_impl( @@ -242,12 +251,13 @@ impl RequestProcessor { ) -> Result { self.ensure_initialized()?; let params: SkillPackageLocalInstallParams = parse_params(params)?; + let changes_lime_catalog = is_lime_app(¶ms.app); let response = self .runtime .install_local_skill_package(params) .await .map_err(to_jsonrpc_error)?; - dispatch_result(response) + dispatch_skill_catalog_mutation(response, changes_lime_catalog) } pub(super) async fn handle_skill_package_local_replace_impl( @@ -256,12 +266,13 @@ impl RequestProcessor { ) -> Result { self.ensure_initialized()?; let params: SkillPackageLocalReplaceParams = parse_params(params)?; + let changes_lime_catalog = is_lime_app(¶ms.app); let response = self .runtime .replace_local_skill_package(params) .await .map_err(to_jsonrpc_error)?; - dispatch_result(response) + dispatch_skill_catalog_mutation(response, changes_lime_catalog) } pub(super) async fn handle_skill_package_export_impl( @@ -284,12 +295,13 @@ impl RequestProcessor { ) -> Result { self.ensure_initialized()?; let params: SkillMarketplaceInstallParams = parse_params(params)?; + let changes_lime_catalog = is_lime_app(¶ms.app); let response = self .runtime .install_marketplace_skill(params) .await .map_err(to_jsonrpc_error)?; - dispatch_result(response) + dispatch_skill_catalog_mutation(response, changes_lime_catalog) } pub(super) async fn handle_skill_download_install_impl( @@ -298,11 +310,30 @@ impl RequestProcessor { ) -> Result { self.ensure_initialized()?; let params: SkillDownloadInstallParams = parse_params(params)?; + let changes_lime_catalog = is_lime_app(¶ms.app); let response = self .runtime .install_skill_from_download_url(params) .await .map_err(to_jsonrpc_error)?; - dispatch_result(response) + dispatch_skill_catalog_mutation(response, changes_lime_catalog) } } + +fn dispatch_skill_catalog_mutation( + response: impl Serialize, + changes_lime_catalog: bool, +) -> Result { + let dispatch = dispatch_result(response)?; + if !changes_lime_catalog { + return Ok(dispatch); + } + lime_skills::invalidate_agent_skill_snapshot_cache(); + Ok(dispatch.with_notification( + V2ServerNotification::SkillsChanged(SkillsChangedNotification {}).into(), + )) +} + +fn is_lime_app(app: &str) -> bool { + app.trim().eq_ignore_ascii_case("lime") +} diff --git a/lime-rs/crates/app-server/src/processor/tests/event_notifications.rs b/lime-rs/crates/app-server/src/processor/tests/event_notifications.rs index 8f07f10c0..5058a05f0 100644 --- a/lime-rs/crates/app-server/src/processor/tests/event_notifications.rs +++ b/lime-rs/crates/app-server/src/processor/tests/event_notifications.rs @@ -50,6 +50,44 @@ fn event_notifications_jsonrpc_emits_direct_agent_message_delta() { ); } +#[test] +fn event_notifications_jsonrpc_emits_direct_turn_plan_update() { + let message = single_event_notification(AgentEvent { + event_id: "evt_plan_1".to_string(), + sequence: 2, + session_id: "sess_1".to_string(), + thread_id: Some("thread_1".to_string()), + turn_id: Some("turn_1".to_string()), + event_type: "turn.plan.updated".to_string(), + timestamp: "2026-07-05T00:00:00Z".to_string(), + payload: json!({ + "explanation": "继续执行", + "plan": [ + { "step": "读取现状", "status": "completed" }, + { "step": "补齐主链", "status": "in_progress" } + ] + }), + }) + .expect("notification"); + + let JsonRpcMessage::Notification(notification) = message else { + panic!("expected notification"); + }; + assert_eq!(notification.method, "turn/plan/updated"); + assert_eq!( + notification.params.expect("params"), + json!({ + "threadId": "thread_1", + "turnId": "turn_1", + "explanation": "继续执行", + "plan": [ + { "step": "读取现状", "status": "completed" }, + { "step": "补齐主链", "status": "inProgress" } + ] + }) + ); +} + #[test] fn event_notifications_jsonrpc_emits_typed_warning_with_localization_code() { let message = single_event_notification(AgentEvent { @@ -128,9 +166,215 @@ fn warning_alias_does_not_activate_typed_notification() { assert_eq!(notification.method, "agentSession/event"); } +fn failed_turn_event(event_id: &str, message: &str) -> AgentEvent { + AgentEvent { + event_id: event_id.to_string(), + sequence: 9, + session_id: "sess_1".to_string(), + thread_id: Some("thread_1".to_string()), + turn_id: Some("turn_1".to_string()), + event_type: "turn.failed".to_string(), + timestamp: "2026-07-05T00:00:01Z".to_string(), + payload: json!({ + "message": message, + "turn": { + "sessionId": "sess_1", + "threadId": "thread_1", + "turnId": "turn_1", + "status": "failed", + "createdAtMs": 100, + "updatedAtMs": 120, + "startedAtMs": 100, + "completedAtMs": 120, + "error": {"message": message} + } + }), + } +} + #[test] -fn event_notifications_jsonrpc_lowers_turn_failed_to_direct_completion() { - let message = single_event_notification(AgentEvent { +fn event_notifications_project_retry_and_terminal_errors_with_exact_semantics() { + let mut projector = V2NotificationProjector::default(); + let retry = project_event_notifications_jsonrpc( + &mut projector, + AgentEvent { + event_id: "evt_retry".to_string(), + sequence: 7, + session_id: "sess_1".to_string(), + thread_id: Some("thread_1".to_string()), + turn_id: Some("turn_1".to_string()), + event_type: "plugin_worker.retry".to_string(), + timestamp: "2026-07-05T00:00:00Z".to_string(), + payload: json!({ + "message": "provider stream reconnecting", + "errorCode": "PLUGIN_WORKER_RETRYABLE_FAILURE", + "retryable": false + }), + }, + ) + .expect("retry notification"); + assert_eq!(retry.len(), 1); + let JsonRpcMessage::Notification(retry) = &retry[0] else { + panic!("expected retry notification"); + }; + assert_eq!(retry.method, "error"); + assert_eq!(retry.params.as_ref().expect("params")["willRetry"], true); + + let terminal = project_event_notifications_jsonrpc( + &mut projector, + failed_turn_event("evt_failed_after_retry", "retry budget exhausted"), + ) + .expect("terminal notifications"); + let terminal = terminal + .into_iter() + .map(|message| match message { + JsonRpcMessage::Notification(notification) => notification, + other => panic!("expected notification, got {other:?}"), + }) + .collect::>(); + assert_eq!( + terminal + .iter() + .map(|notification| notification.method.as_str()) + .collect::>(), + ["error", "turn/completed"] + ); + assert_eq!( + terminal[0].params.as_ref().expect("error params")["willRetry"], + false + ); + assert_eq!( + terminal[1].params.as_ref().expect("completion params")["turn"]["status"], + "failed" + ); +} + +#[test] +fn runtime_error_is_terminal_even_when_legacy_retryable_is_true_and_is_not_duplicated() { + let mut projector = V2NotificationProjector::default(); + let runtime_error = project_event_notifications_jsonrpc( + &mut projector, + AgentEvent { + event_id: "evt_runtime_error".to_string(), + sequence: 8, + session_id: "sess_1".to_string(), + thread_id: Some("thread_1".to_string()), + turn_id: Some("turn_1".to_string()), + event_type: "runtime.error".to_string(), + timestamp: "2026-07-05T00:00:00Z".to_string(), + payload: json!({ + "message": "retry budget exhausted", + "errorCode": "PLUGIN_WORKER_RETRYABLE_FAILURE", + "retryable": true + }), + }, + ) + .expect("runtime error notification"); + let JsonRpcMessage::Notification(runtime_error) = &runtime_error[0] else { + panic!("expected runtime error notification"); + }; + let params = runtime_error.params.as_ref().expect("runtime error params"); + assert_eq!(runtime_error.method, "error"); + assert_eq!(params["willRetry"], false); + assert_eq!(params["error"]["codexErrorInfo"], "other"); + + let completion = project_event_notifications_jsonrpc( + &mut projector, + failed_turn_event("evt_failed_after_runtime_error", "retry budget exhausted"), + ) + .expect("completion after terminal error"); + assert_eq!(completion.len(), 1); + let JsonRpcMessage::Notification(completion) = &completion[0] else { + panic!("expected turn completion"); + }; + assert_eq!(completion.method, "turn/completed"); +} + +#[test] +fn turn_failed_without_runtime_error_emits_error_before_completion() { + let messages = event_notifications_jsonrpc(failed_turn_event( + "evt_failed_without_error", + "provider stream timed out", + )) + .expect("failed turn notifications"); + let methods = messages + .iter() + .map(|message| match message { + JsonRpcMessage::Notification(notification) => notification.method.as_str(), + other => panic!("expected notification, got {other:?}"), + }) + .collect::>(); + assert_eq!(methods, ["error", "turn/completed"]); +} + +#[test] +fn malformed_typed_error_sources_fail_closed() { + for (index, event) in [ + AgentEvent { + event_id: "missing_thread".to_string(), + sequence: 1, + session_id: "sess_1".to_string(), + thread_id: None, + turn_id: Some("turn_1".to_string()), + event_type: "runtime.error".to_string(), + timestamp: "2026-07-05T00:00:00Z".to_string(), + payload: json!({"message": "failed"}), + }, + AgentEvent { + event_id: "missing_turn".to_string(), + sequence: 1, + session_id: "sess_1".to_string(), + thread_id: Some("thread_1".to_string()), + turn_id: None, + event_type: "runtime.error".to_string(), + timestamp: "2026-07-05T00:00:00Z".to_string(), + payload: json!({"message": "failed"}), + }, + AgentEvent { + event_id: "missing_message".to_string(), + sequence: 1, + session_id: "sess_1".to_string(), + thread_id: Some("thread_1".to_string()), + turn_id: Some("turn_1".to_string()), + event_type: "runtime.error".to_string(), + timestamp: "2026-07-05T00:00:00Z".to_string(), + payload: json!({}), + }, + AgentEvent { + event_id: "invalid_retry".to_string(), + sequence: 1, + session_id: "sess_1".to_string(), + thread_id: Some("thread_1".to_string()), + turn_id: Some("turn_1".to_string()), + event_type: "runtime.error".to_string(), + timestamp: "2026-07-05T00:00:00Z".to_string(), + payload: json!({"message": "failed", "willRetry": "false"}), + }, + AgentEvent { + event_id: "invalid_details".to_string(), + sequence: 1, + session_id: "sess_1".to_string(), + thread_id: Some("thread_1".to_string()), + turn_id: Some("turn_1".to_string()), + event_type: "runtime.error".to_string(), + timestamp: "2026-07-05T00:00:00Z".to_string(), + payload: json!({"message": "failed", "additionalDetails": 42}), + }, + ] + .into_iter() + .enumerate() + { + let error = match event_notifications_jsonrpc(event) { + Ok(messages) => panic!("case {index} projected {messages:?}"), + Err(error) => error, + }; + assert_eq!(error.code, app_server_protocol::error_codes::RUNTIME_ERROR); + } +} + +#[test] +fn event_notifications_jsonrpc_lowers_turn_failed_to_error_then_completion() { + let messages = event_notifications_jsonrpc(AgentEvent { event_id: "evt_failed".to_string(), sequence: 2, session_id: "sess_1".to_string(), @@ -153,13 +397,14 @@ fn event_notifications_jsonrpc_lowers_turn_failed_to_direct_completion() { } }), }) - .expect("notification"); + .expect("notifications"); - let JsonRpcMessage::Notification(notification) = message else { + assert_eq!(messages.len(), 2); + let JsonRpcMessage::Notification(notification) = &messages[1] else { panic!("expected notification"); }; assert_eq!(notification.method, "turn/completed"); - let params = notification.params.expect("params"); + let params = notification.params.as_ref().expect("params"); assert_eq!(params["threadId"], "thread_1"); assert_eq!(params["turn"]["id"], "turn_1"); assert_eq!(params["turn"]["status"], "failed"); diff --git a/lime-rs/crates/app-server/src/processor/tests/mcp.rs b/lime-rs/crates/app-server/src/processor/tests/mcp.rs index 0277b9790..2dc2cc484 100644 --- a/lime-rs/crates/app-server/src/processor/tests/mcp.rs +++ b/lime-rs/crates/app-server/src/processor/tests/mcp.rs @@ -1,18 +1,99 @@ //! mcp request processor tests. use super::super::*; +use super::tests_support::initialize_processor; use app_server_protocol::{ ClientCapabilities, JsonRpcMessage, RequestId, METHOD_INITIALIZE, METHOD_INITIALIZED, METHOD_MCP_PROMPT_GET, METHOD_MCP_PROMPT_LIST, METHOD_MCP_RESOURCE_LIST, METHOD_MCP_RESOURCE_READ, METHOD_MCP_RESOURCE_SUBSCRIBE, METHOD_MCP_RESOURCE_UNSUBSCRIBE, METHOD_MCP_SERVER_CREATE, METHOD_MCP_SERVER_DELETE, METHOD_MCP_SERVER_ENABLED_SET, - METHOD_MCP_SERVER_IMPORT_FROM_APP, METHOD_MCP_SERVER_LIST, METHOD_MCP_SERVER_START, - METHOD_MCP_SERVER_STATUS_LIST, METHOD_MCP_SERVER_STOP, METHOD_MCP_SERVER_SYNC_ALL_TO_LIVE, - METHOD_MCP_SERVER_UPDATE, METHOD_MCP_TOOL_CALL, METHOD_MCP_TOOL_CALL_WITH_CALLER, - METHOD_MCP_TOOL_LIST, + METHOD_MCP_SERVER_IMPORT_FROM_APP, METHOD_MCP_SERVER_LIST, METHOD_MCP_SERVER_OAUTH_LOGIN, + METHOD_MCP_SERVER_START, METHOD_MCP_SERVER_STATUS_LIST, METHOD_MCP_SERVER_STOP, + METHOD_MCP_SERVER_SYNC_ALL_TO_LIVE, METHOD_MCP_SERVER_UPDATE, METHOD_MCP_TOOL_CALL, + METHOD_MCP_TOOL_CALL_WITH_CALLER, METHOD_MCP_TOOL_LIST, }; +use async_trait::async_trait; use serde_json::json; +#[derive(Default)] +struct OAuthTestDataSource { + login: std::sync::Mutex>, +} + +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 {} + +#[async_trait] +impl crate::McpAppDataSource for OAuthTestDataSource { + async fn login_mcp_server_oauth( + &self, + _params: app_server_protocol::McpServerOauthLoginParams, + ) -> Result { + self.login + .lock() + .expect("OAuth test login mutex poisoned") + .take() + .ok_or_else(|| RuntimeCoreError::Backend("OAuth test login missing".to_string())) + } +} + +fn oauth_test_data_source() -> ( + Arc, + tokio::sync::oneshot::Sender>, +) { + let (completion_tx, completion_rx) = tokio::sync::oneshot::channel::>(); + let handle = lime_mcp::McpOAuthLoginHandle::new( + "https://auth.example/authorize", + "pending", + async move { + completion_rx + .await + .map_err(|error| lime_mcp::McpError::ProtocolError(error.to_string()))? + .map_err(lime_mcp::McpError::ConfigError) + }, + ); + ( + Arc::new(OAuthTestDataSource { + login: std::sync::Mutex::new(Some(handle)), + }), + completion_tx, + ) +} + +async fn start_oauth_test() -> ( + RequestProcessor, + tokio::sync::oneshot::Sender>, + tokio::sync::mpsc::UnboundedReceiver, +) { + let (data_source, completion_tx) = oauth_test_data_source(); + 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, completion_tx, notification_rx) +} + #[tokio::test] async fn mcp_list_methods_require_initialized_and_return_current_empty_state() { let processor = RequestProcessor::new(RuntimeCore::default()); @@ -232,3 +313,79 @@ async fn mcp_runtime_methods_require_initialized_and_fail_closed_without_manager } } } + +#[tokio::test] +async fn mcp_oauth_login_returns_before_typed_success_notification() { + let (processor, completion_tx, mut notification_rx) = start_oauth_test().await; + let messages = processor + .handle_request(JsonRpcRequest::new( + RequestId::Integer(30), + METHOD_MCP_SERVER_OAUTH_LOGIN, + Some(json!({ "name": "remote-docs" })), + )) + .await + .expect("OAuth login response"); + + let [JsonRpcMessage::Response(response)] = messages.as_slice() else { + panic!("expected OAuth login response, got {messages:?}"); + }; + assert_eq!( + response.result["authorizationUrl"], + "https://auth.example/authorize" + ); + assert_eq!(response.result["state"], "pending"); + + completion_tx.send(Ok(())).expect("complete OAuth login"); + let notification = + tokio::time::timeout(std::time::Duration::from_secs(1), notification_rx.recv()) + .await + .expect("OAuth success notification timeout") + .expect("OAuth success notification channel closed"); + assert_eq!( + notification, + app_server_protocol::protocol::v2::ServerNotification::McpServerOauthLoginCompleted( + app_server_protocol::protocol::v2::McpServerOauthLoginCompletedNotification { + name: "remote-docs".to_string(), + thread_id: None, + success: true, + error: None, + } + ) + ); +} + +#[tokio::test] +async fn mcp_oauth_login_publishes_typed_failure_notification() { + let (processor, completion_tx, mut notification_rx) = start_oauth_test().await; + let messages = processor + .handle_request(JsonRpcRequest::new( + RequestId::Integer(31), + METHOD_MCP_SERVER_OAUTH_LOGIN, + Some(json!({ "name": "remote-docs" })), + )) + .await + .expect("OAuth login response"); + assert!(matches!(messages.as_slice(), [JsonRpcMessage::Response(_)])); + + completion_tx + .send(Err("scope rejected".to_string())) + .expect("fail OAuth login"); + let notification = + tokio::time::timeout(std::time::Duration::from_secs(1), notification_rx.recv()) + .await + .expect("OAuth failure notification timeout") + .expect("OAuth failure notification channel closed"); + let app_server_protocol::protocol::v2::ServerNotification::McpServerOauthLoginCompleted( + notification, + ) = notification + else { + panic!("expected OAuth completion notification"); + }; + assert_eq!(notification.name, "remote-docs"); + assert_eq!(notification.thread_id, None); + assert!(!notification.success); + assert!(notification + .error + .as_deref() + .is_some_and(|error| error.contains("scope rejected"))); +} diff --git a/lime-rs/crates/app-server/src/processor/thread/projection.rs b/lime-rs/crates/app-server/src/processor/thread/projection.rs index a7bb0afe2..fe8771817 100644 --- a/lime-rs/crates/app-server/src/processor/thread/projection.rs +++ b/lime-rs/crates/app-server/src/processor/thread/projection.rs @@ -296,7 +296,9 @@ fn project_turn(turn: canonical::Turn) -> Result { status: project_turn_status(turn.status), error: turn.error.map(|error| v2::TurnError { message: error.message, - error_info: error.code.map(Value::String), + codex_error_info: crate::processor::v2_notifications::error::codex_error_info_from_code( + error.code.as_deref(), + ), additional_details: error.details, }), started_at: turn.started_at_ms.map(millis_to_seconds), diff --git a/lime-rs/crates/app-server/src/processor/v2_notifications.rs b/lime-rs/crates/app-server/src/processor/v2_notifications.rs index f3d2fcfbf..3c7b85a75 100644 --- a/lime-rs/crates/app-server/src/processor/v2_notifications.rs +++ b/lime-rs/crates/app-server/src/processor/v2_notifications.rs @@ -6,10 +6,12 @@ use serde_json::Value; use std::collections::HashSet; mod command; +pub(crate) mod error; mod file_change; mod mcp; mod plan; mod thread_status; +mod turn_plan; mod warning; enum EventProjection { @@ -31,6 +33,7 @@ pub(crate) struct V2NotificationProjector { completed_file_change_item_ids: HashSet, started_mcp_item_ids: HashSet, completed_mcp_item_ids: HashSet, + terminal_error_turn_ids: HashSet, thread_status: thread_status::ThreadStatusProjector, } @@ -54,12 +57,12 @@ impl V2NotificationProjector { "turn.completed" => { return self.project_turn_completed_with_usage(event, v2::TurnStatus::Completed) } - "turn.failed" => { - return self.project_turn_completed_with_usage(event, v2::TurnStatus::Failed) - } + "turn.failed" => return self.project_turn_failed(event), "turn.canceled" => { return self.project_turn_completed_with_usage(event, v2::TurnStatus::Interrupted) } + "plugin_worker.retry" => return self.project_error(event, Some(true)), + "runtime.error" => return self.project_error(event, None), "action.required" => { return EventProjection::Direct( self.thread_status @@ -120,6 +123,7 @@ impl V2NotificationProjector { event, ) } + "turn.plan.updated" => return turn_plan::project(event), "tool.progress" => { return mcp::project_progress( &self.started_mcp_item_ids, @@ -233,6 +237,42 @@ impl V2NotificationProjector { EventProjection::Direct(notifications) } + fn project_error( + &mut self, + event: &AgentEvent, + forced_will_retry: Option, + ) -> EventProjection { + let Some(projected) = error::project(event, forced_will_retry) else { + return EventProjection::Reject(projection_error(event)); + }; + if !projected.will_retry + && !self + .terminal_error_turn_ids + .insert(projected.turn_id.clone()) + { + return EventProjection::Direct(Vec::new()); + } + EventProjection::Direct(vec![projected.notification.into()]) + } + + fn project_turn_failed(&mut self, event: &AgentEvent) -> EventProjection { + let Some(turn_id) = required_event_id(event.turn_id.as_deref()) else { + return EventProjection::Reject(projection_error(event)); + }; + let completion = self.project_turn_completed_with_usage(event, v2::TurnStatus::Failed); + let EventProjection::Direct(mut notifications) = completion else { + return completion; + }; + if self.terminal_error_turn_ids.remove(&turn_id) { + return EventProjection::Direct(notifications); + } + let Some(projected) = error::project(event, Some(false)) else { + return EventProjection::Reject(projection_error(event)); + }; + notifications.insert(0, projected.notification.into()); + EventProjection::Direct(notifications) + } + fn project_item(&mut self, event: &AgentEvent, completed: bool) -> Option { let thread_id = required_event_id(event.thread_id.as_deref())?; let turn_id = required_event_id(event.turn_id.as_deref())?; @@ -880,35 +920,44 @@ mod tests { ( "turn.completed", json!({"turn": canonical_turn("completed")}), - "turn/completed", + vec!["turn/completed"], ), ( "turn.failed", - json!({"turn": canonical_turn("failed")}), - "turn/completed", + json!({ + "message": "provider failed", + "turn": canonical_turn("failed") + }), + vec!["error", "turn/completed"], ), ( "turn.canceled", json!({"turn": canonical_turn("interrupted")}), - "turn/completed", + vec!["turn/completed"], ), ( "item.started", json!({"item": canonical_item("inProgress")}), - "item/started", + vec!["item/started"], ), ( "item.completed", json!({"item": canonical_item("completed")}), - "item/completed", + vec!["item/completed"], ), ]; - for (event_type, payload, method) in cases { + for (event_type, payload, methods) in cases { let mut projector = V2NotificationProjector::default(); let notifications = projector .project(event(event_type, payload)) .expect("direct lifecycle"); - assert_eq!(notifications[0].method, method); + assert_eq!( + notifications + .iter() + .map(|notification| notification.method.as_str()) + .collect::>(), + methods + ); } } diff --git a/lime-rs/crates/app-server/src/processor/v2_notifications/error.rs b/lime-rs/crates/app-server/src/processor/v2_notifications/error.rs new file mode 100644 index 000000000..1f220e892 --- /dev/null +++ b/lime-rs/crates/app-server/src/processor/v2_notifications/error.rs @@ -0,0 +1,167 @@ +use app_server_protocol::protocol::v2::{ + CodexErrorInfo, ErrorNotification, ServerNotification, TurnError, +}; +use app_server_protocol::AgentEvent; +use serde_json::Value; + +pub(super) struct ProjectedError { + pub(super) notification: ServerNotification, + pub(super) turn_id: String, + pub(super) will_retry: bool, +} + +pub(super) fn project( + event: &AgentEvent, + forced_will_retry: Option, +) -> Option { + let thread_id = required_id(event.thread_id.as_deref())?; + let turn_id = required_id(event.turn_id.as_deref())?; + let message = error_message(&event.payload)?; + let additional_details = optional_string( + &event.payload, + &["additionalDetails", "additional_details", "details"], + ) + .or_else(|| nested_turn_error_string(&event.payload, "additionalDetails")) + .or_else(|| nested_turn_error_string(&event.payload, "details")); + if has_invalid_optional_string( + &event.payload, + &["additionalDetails", "additional_details", "details"], + ) { + return None; + } + let will_retry = match forced_will_retry { + Some(will_retry) => will_retry, + None => explicit_will_retry(&event.payload)?, + }; + let code = payload_string(&event.payload, &["errorCode", "error_code", "code"]) + .or_else(|| nested_turn_error_string(&event.payload, "code")); + + Some(ProjectedError { + notification: ServerNotification::Error(ErrorNotification { + error: TurnError { + message, + codex_error_info: codex_error_info_from_code(code.as_deref()), + additional_details, + }, + will_retry, + thread_id, + turn_id: turn_id.clone(), + }), + turn_id, + will_retry, + }) +} + +pub(crate) fn codex_error_info_from_code(code: Option<&str>) -> Option { + let code = code?; + let normalized = code + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::(); + Some(match normalized.as_str() { + "contextwindowexceeded" => CodexErrorInfo::ContextWindowExceeded, + "sessionbudgetexceeded" => CodexErrorInfo::SessionBudgetExceeded, + "usagelimitexceeded" => CodexErrorInfo::UsageLimitExceeded, + "serveroverloaded" => CodexErrorInfo::ServerOverloaded, + "cyberpolicy" => CodexErrorInfo::CyberPolicy, + "httpconnectionfailed" => CodexErrorInfo::HttpConnectionFailed { + http_status_code: None, + }, + "responsestreamconnectionfailed" => CodexErrorInfo::ResponseStreamConnectionFailed { + http_status_code: None, + }, + "internalservererror" => CodexErrorInfo::InternalServerError, + "unauthorized" => CodexErrorInfo::Unauthorized, + "badrequest" => CodexErrorInfo::BadRequest, + "threadrollbackfailed" => CodexErrorInfo::ThreadRollbackFailed, + "sandboxerror" => CodexErrorInfo::SandboxError, + "responsestreamdisconnected" => CodexErrorInfo::ResponseStreamDisconnected { + http_status_code: None, + }, + "responsetoomanyfailedattempts" => CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: None, + }, + _ => CodexErrorInfo::Other, + }) +} + +fn error_message(payload: &Value) -> Option { + payload_string(payload, &["message", "errorMessage", "error_message"]) + .or_else(|| payload_string(payload, &["error"])) + .or_else(|| nested_error_string(payload, "message")) + .or_else(|| nested_turn_error_string(payload, "message")) +} + +fn explicit_will_retry(payload: &Value) -> Option { + let mut result = None; + for key in ["willRetry", "will_retry"] { + let Some(value) = payload.get(key) else { + continue; + }; + let value = value.as_bool()?; + if result.is_some_and(|current| current != value) { + return None; + } + result = Some(value); + } + Some(result.unwrap_or(false)) +} + +fn required_id(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn payload_string(payload: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + payload + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) +} + +fn optional_string(payload: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| match payload.get(key) { + Some(Value::String(value)) => Some(value.clone()), + _ => None, + }) +} + +fn has_invalid_optional_string(payload: &Value, keys: &[&str]) -> bool { + keys.iter().any(|key| { + !matches!( + payload.get(key), + None | Some(Value::Null) | Some(Value::String(_)) + ) + }) +} + +fn nested_error_string(payload: &Value, key: &str) -> Option { + payload + .get("error") + .and_then(Value::as_object) + .and_then(|error| error.get(key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn nested_turn_error_string(payload: &Value, key: &str) -> Option { + payload + .get("turn") + .and_then(Value::as_object) + .and_then(|turn| turn.get("error")) + .and_then(Value::as_object) + .and_then(|error| error.get(key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} diff --git a/lime-rs/crates/app-server/src/processor/v2_notifications/turn_plan.rs b/lime-rs/crates/app-server/src/processor/v2_notifications/turn_plan.rs new file mode 100644 index 000000000..65634115b --- /dev/null +++ b/lime-rs/crates/app-server/src/processor/v2_notifications/turn_plan.rs @@ -0,0 +1,97 @@ +use super::{projection_error, required_event_id, EventProjection}; +use app_server_protocol::protocol::v2::{ + self, ServerNotification, TurnPlanStep, TurnPlanStepStatus, +}; +use app_server_protocol::AgentEvent; +use serde_json::Value; +use tool_runtime::update_plan::{PlanStep, PlanStepStatus}; + +pub(super) fn project(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(plan_value) = event.payload.get("plan") else { + return EventProjection::Reject(projection_error(event)); + }; + let Ok(plan) = serde_json::from_value::>(plan_value.clone()) else { + return EventProjection::Reject(projection_error(event)); + }; + let explanation = match event.payload.get("explanation") { + None | Some(Value::Null) => None, + Some(Value::String(value)) => Some(value.clone()), + Some(_) => return EventProjection::Reject(projection_error(event)), + }; + + EventProjection::Direct(vec![ServerNotification::TurnPlanUpdated( + v2::TurnPlanUpdatedNotification { + thread_id, + turn_id, + explanation, + plan: plan.into_iter().map(project_step).collect(), + }, + ) + .into()]) +} + +fn project_step(step: PlanStep) -> TurnPlanStep { + TurnPlanStep { + step: step.step, + status: match step.status { + PlanStepStatus::Pending => TurnPlanStepStatus::Pending, + PlanStepStatus::InProgress => TurnPlanStepStatus::InProgress, + PlanStepStatus::Completed => TurnPlanStepStatus::Completed, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn event(payload: Value) -> AgentEvent { + AgentEvent { + event_id: "evt-plan-1".to_string(), + sequence: 3, + session_id: "session-plan-1".to_string(), + thread_id: Some("thread-plan-1".to_string()), + turn_id: Some("turn-plan-1".to_string()), + event_type: "turn.plan.updated".to_string(), + timestamp: "2026-07-31T00:00:00.000Z".to_string(), + payload, + } + } + + #[test] + fn projects_update_plan_checklist_without_plan_item_lifecycle() { + let EventProjection::Direct(notifications) = project(&event(json!({ + "explanation": "继续实现", + "plan": [ + { "step": "读现状", "status": "completed" }, + { "step": "补主链", "status": "in_progress" } + ] + }))) else { + panic!("turn plan update should project directly"); + }; + + assert_eq!(notifications.len(), 1); + assert_eq!(notifications[0].method, "turn/plan/updated"); + let params = notifications[0].params.as_ref().expect("params"); + assert_eq!(params["threadId"], "thread-plan-1"); + assert_eq!(params["turnId"], "turn-plan-1"); + assert_eq!(params["plan"][1]["status"], "inProgress"); + } + + #[test] + fn rejects_invalid_update_plan_status() { + assert!(matches!( + project(&event(json!({ + "plan": [{ "step": "补主链", "status": "running" }] + }))), + EventProjection::Reject(_) + )); + } +} diff --git a/lime-rs/crates/app-server/src/runtime/app_data/mcp.rs b/lime-rs/crates/app-server/src/runtime/app_data/mcp.rs index 0d746a3f9..6306b2201 100644 --- a/lime-rs/crates/app-server/src/runtime/app_data/mcp.rs +++ b/lime-rs/crates/app-server/src/runtime/app_data/mcp.rs @@ -3,7 +3,7 @@ use super::NoopAppDataSource; use super::RuntimeCoreError; use app_server_protocol::*; use async_trait::async_trait; -use lime_mcp::McpRuntimeServerSpec; +use lime_mcp::{McpOAuthLoginHandle, McpRuntimeServerSpec}; #[async_trait] pub trait McpAppDataSource: Send + Sync { @@ -79,7 +79,7 @@ pub trait McpAppDataSource: Send + Sync { async fn login_mcp_server_oauth( &self, _params: McpServerOauthLoginParams, - ) -> Result { + ) -> Result { Err(requires_current("mcpServer/oauth/login")) } diff --git a/lime-rs/crates/app-server/src/runtime/load_context.rs b/lime-rs/crates/app-server/src/runtime/load_context.rs index 0af3e5fcb..156dd0a8b 100644 --- a/lime-rs/crates/app-server/src/runtime/load_context.rs +++ b/lime-rs/crates/app-server/src/runtime/load_context.rs @@ -229,7 +229,7 @@ pub(in crate::runtime) async fn projection_load_context( .collect(), }; let mut detail = if stored.events.is_empty() && params.history_limit.is_some() { - let (mut usage_events, warning_events): (Vec<_>, Vec<_>) = projection_support_events + let (mut usage_events, notification_events): (Vec<_>, Vec<_>) = projection_support_events .into_iter() .partition(is_turn_completed_usage_event); usage_events.extend( @@ -243,7 +243,7 @@ pub(in crate::runtime) async fn projection_load_context( &projection, params, &usage_events, - &warning_events, + ¬ification_events, projection_store, ) .await? @@ -288,7 +288,7 @@ async fn projection_summary_detail( projection: &ProjectionReadSession, params: &AgentSessionReadParams, usage_events: &[AgentEvent], - warning_events: &[AgentEvent], + notification_events: &[AgentEvent], projection_store: &super::ProjectionStore, ) -> Result { let messages = projection.messages.clone(); @@ -300,10 +300,10 @@ async fn projection_summary_detail( let canonical_items = read_model::canonical_items_from_thread_store(projection_store, stored) .await .map_err(|error| RuntimeCoreError::Backend(error.to_string()))?; - let mut warning_stored = stored.clone(); - warning_stored.events = warning_events.to_vec(); - let items = Value::Array(read_model::merge_runtime_warning_items( - &warning_stored, + let mut notification_stored = stored.clone(); + notification_stored.events = notification_events.to_vec(); + let items = Value::Array(read_model::merge_runtime_notification_items( + ¬ification_stored, canonical_items, )); let thread_items = items.clone(); diff --git a/lime-rs/crates/app-server/src/runtime/mcp.rs b/lime-rs/crates/app-server/src/runtime/mcp.rs index 3bd932e1a..4ba0dfeb4 100644 --- a/lime-rs/crates/app-server/src/runtime/mcp.rs +++ b/lime-rs/crates/app-server/src/runtime/mcp.rs @@ -72,7 +72,7 @@ impl RuntimeCore { pub async fn login_mcp_server_oauth( &self, params: McpServerOauthLoginParams, - ) -> Result { + ) -> Result { self.app_data_source.login_mcp_server_oauth(params).await } diff --git a/lime-rs/crates/app-server/src/runtime/read_model.rs b/lime-rs/crates/app-server/src/runtime/read_model.rs index d950a90d0..a4f0e0b5b 100644 --- a/lime-rs/crates/app-server/src/runtime/read_model.rs +++ b/lime-rs/crates/app-server/src/runtime/read_model.rs @@ -48,13 +48,14 @@ use thread_store::{ListItemsParams, PageRequest, ThreadStore, ThreadStoreResult} pub(super) use canonical_items::canonical_item_to_agent_detail; pub(super) use messages::runtime_session_messages; -pub(super) fn merge_runtime_warning_items( +pub(super) fn merge_runtime_notification_items( stored: &StoredSession, mut items: Vec, ) -> Vec { - // Warning notifications are durable runtime events, not canonical ThreadItem - // variants. Re-derive them beside canonical items for every cold-read path. + // Runtime notifications are durable events, not canonical ThreadItem variants. + // Re-derive them beside canonical items for every cold-read path. items.extend(runtime_warning_items_from_events(stored)); + items.extend(runtime_error_items_from_events(stored)); sort_read_detail_items(&mut items); items } @@ -151,7 +152,7 @@ fn runtime_session_read_detail_with_item_source( sort_read_detail_items(&mut items); items }, - |items| merge_runtime_warning_items(stored, items.to_vec()), + |items| merge_runtime_notification_items(stored, items.to_vec()), ); if canonical_items.is_some() { apply_canonical_item_views(&mut thread_read, &items); diff --git a/lime-rs/crates/app-server/src/runtime/read_model/tests.rs b/lime-rs/crates/app-server/src/runtime/read_model/tests.rs index bfabaf51e..caa93777b 100644 --- a/lime-rs/crates/app-server/src/runtime/read_model/tests.rs +++ b/lime-rs/crates/app-server/src/runtime/read_model/tests.rs @@ -85,6 +85,47 @@ fn canonical_read_keeps_runtime_warning_message_and_localization_code() { assert_eq!(detail["thread_read"]["thread_items"], detail["items"]); } +#[test] +fn canonical_read_keeps_runtime_error_without_terminalizing_the_turn() { + let mut stored = stored_running_session("2026-07-31T00:00:00Z", "2026-07-31T00:00:01Z"); + stored.events.push(AgentEvent { + event_id: "event-read-model-error".to_string(), + sequence: 2, + session_id: stored.session.session_id.clone(), + thread_id: Some(stored.session.thread_id.clone()), + turn_id: Some(stored.turns[0].turn_id.clone()), + event_type: "runtime.error".to_string(), + timestamp: "2026-07-31T00:00:02Z".to_string(), + payload: json!({ + "errorCode": "server_overloaded", + "message": "服务暂时不可用,等待权威终态。", + "willRetry": false + }), + }); + + let detail = runtime_session_read_detail_with_item_source( + &stored, + ReadDetailOptions::default(), + &[], + Some(&[]), + ); + let error = detail["items"] + .as_array() + .and_then(|items| items.iter().find(|item| item["type"] == "error")) + .expect("error item"); + + assert_eq!(error["thread_id"], stored.session.thread_id); + assert_eq!(error["turn_id"], stored.turns[0].turn_id); + assert_eq!(error["message"], "服务暂时不可用,等待权威终态。"); + assert_eq!(detail["turns"][0]["status"], "running"); + assert_eq!( + detail["thread_read"]["diagnostics"]["latest_turn_status"], + "running" + ); + assert!(detail["turns"][0]["completed_at"].is_null()); + assert_eq!(detail["thread_read"]["thread_items"], detail["items"]); +} + #[test] fn canonical_read_rejects_malformed_runtime_warning_shapes_and_identity() { let mut stored = stored_running_session("2026-07-31T00:00:00Z", "2026-07-31T00:00:01Z"); diff --git a/lime-rs/crates/app-server/src/runtime_backend/plan_events.rs b/lime-rs/crates/app-server/src/runtime_backend/plan_events.rs index b9d2f9af8..dda6f11d3 100644 --- a/lime-rs/crates/app-server/src/runtime_backend/plan_events.rs +++ b/lime-rs/crates/app-server/src/runtime_backend/plan_events.rs @@ -1,8 +1,6 @@ use crate::RuntimeEvent; +use agent_protocol::ToolOutput; use serde_json::{json, Value}; -use std::collections::HashMap; - -const UPDATE_PLAN_ACK: &str = "Plan updated"; pub fn plan_delta_event(text: impl Into, revision_id: impl Into) -> RuntimeEvent { let text = text.into(); @@ -68,52 +66,31 @@ pub fn proposed_plan_final_event( event } -pub fn plan_final_event_from_update_plan_result( +pub fn turn_plan_updated_event_from_update_plan_result( tool_id: &str, - output: &str, - metadata: Option<&HashMap>, + output: &ToolOutput, ) -> Option { - if output.trim() != UPDATE_PLAN_ACK { + if output.text.as_deref()?.trim() != tool_runtime::update_plan::PLAN_UPDATED_MESSAGE { return None; } - let metadata = metadata?; - let plan = metadata.get("plan")?.clone(); - let text = plan_text_from_plan_value(&plan)?; - let revision_id = format!("update_plan:{tool_id}"); - let mut event = plan_final_event(text, revision_id, Some(plan)); - if let Some(payload_object) = event.payload.as_object_mut() { - payload_object.insert("toolCallId".to_string(), Value::String(tool_id.to_string())); - payload_object.insert( - "source".to_string(), - Value::String("update_plan".to_string()), - ); - if let Some(explanation) = metadata.get("explanation") { - if !explanation.is_null() { - payload_object.insert("explanation".to_string(), explanation.clone()); - } - } - } - Some(event) -} - -fn plan_text_from_plan_value(plan: &Value) -> Option { - let lines = plan - .as_array()? - .iter() - .filter_map(|item| { - let step = item.get("step")?.as_str()?.trim(); - if step.is_empty() { - return None; - } - let status = item - .get("status") - .and_then(Value::as_str) - .unwrap_or("pending"); - let marker = if status == "completed" { "[x]" } else { "[ ]" }; - Some(format!("- {marker} {step}")) - }) - .collect::>(); - (!lines.is_empty()).then(|| lines.join("\n")) + let structured_content = output.structured_content.as_ref()?.as_object()?; + let plan = serde_json::from_value::>( + structured_content.get("plan")?.clone(), + ) + .ok()?; + let explanation = match structured_content.get("explanation") { + None | Some(Value::Null) => None, + Some(Value::String(explanation)) => Some(explanation.clone()), + Some(_) => return None, + }; + Some(RuntimeEvent::new( + "turn.plan.updated", + json!({ + "toolCallId": tool_id, + "explanation": explanation, + "plan": plan, + }), + )) } fn plan_value_from_markdown_text(text: &str) -> Option { @@ -190,29 +167,41 @@ mod tests { } #[test] - fn builds_update_plan_final_event_from_tool_metadata() { - let event = plan_final_event_from_update_plan_result( + fn builds_turn_plan_updated_event_from_canonical_tool_output() { + let event = turn_plan_updated_event_from_update_plan_result( "tool-plan-1", - "Plan updated", - Some(&HashMap::from([ - ( - "plan".to_string(), - json!([ + &ToolOutput { + text: Some("Plan updated".to_string()), + structured_content: Some(json!({ + "tool_family": "update_plan", + "explanation": "继续实现", + "plan": [ { "step": "读现状", "status": "completed" }, { "step": "补主链", "status": "in_progress" } - ]), - ), - ("explanation".to_string(), json!("继续实现")), - ])), + ] + })), + ..ToolOutput::default() + }, ) - .expect("update_plan result should become plan.final"); + .expect("update_plan result should become turn.plan.updated"); - assert_eq!(event.event_type, "plan.final"); - assert_eq!(event.payload["revisionId"], "update_plan:tool-plan-1"); + assert_eq!(event.event_type, "turn.plan.updated"); assert_eq!(event.payload["toolCallId"], "tool-plan-1"); - assert_eq!(event.payload["source"], "update_plan"); assert_eq!(event.payload["explanation"], "继续实现"); - assert_eq!(event.payload["text"], "- [x] 读现状\n- [ ] 补主链"); assert_eq!(event.payload["plan"][1]["status"], "in_progress"); } + + #[test] + fn ignores_update_plan_ack_without_canonical_structured_output() { + let event = turn_plan_updated_event_from_update_plan_result( + "tool-plan-1", + &ToolOutput { + text: Some("Plan updated".to_string()), + structured_content: None, + ..ToolOutput::default() + }, + ); + + assert!(event.is_none()); + } } diff --git a/lime-rs/crates/app-server/src/runtime_backend/tool_events.rs b/lime-rs/crates/app-server/src/runtime_backend/tool_events.rs index d924ff830..d93b378c7 100644 --- a/lime-rs/crates/app-server/src/runtime_backend/tool_events.rs +++ b/lime-rs/crates/app-server/src/runtime_backend/tool_events.rs @@ -174,19 +174,13 @@ fn update_plan_event_from_completed_tool(event: &RuntimeAgentEvent) -> Option &'static str { @@ -208,6 +202,7 @@ fn provider_stream_runtime_event_type(runtime_event_kind: &str) -> &'static str struct CanonicalToolItem<'a> { item: &'a ThreadItem, call_id: &'a str, + name: &'a str, output: Option<&'a ToolOutput>, } @@ -219,7 +214,10 @@ fn canonical_tool_item(event: &RuntimeAgentEvent) -> Option return None, }; let ThreadItemPayload::Tool { - call_id, output, .. + call_id, + name, + output, + .. } = &item.payload else { return None; @@ -227,6 +225,7 @@ fn canonical_tool_item(event: &RuntimeAgentEvent) -> Option>(), - vec!["item.completed", "plan.final"] + vec!["item.completed", "turn.plan.updated"] ); - assert_eq!(events[1].payload["revisionId"], "update_plan:tool-plan"); + assert_eq!(events[1].payload["toolCallId"], "tool-plan"); + assert_eq!(events[1].payload["explanation"], "开始实现"); + assert_eq!(events[1].payload["plan"][1]["status"], "in_progress"); + } + + #[test] + fn non_update_plan_completion_does_not_emit_turn_plan_updated_fact() { + let events = runtime_events_from_agent_event(&canonical_tool_event( + "tool-other", + "other_tool", + Some(json!({})), + Some(AgentToolResult { + success: true, + output: "Plan updated".to_string(), + error: None, + structured_content: Some(json!({ + "plan": [{ "step": "不应投影", "status": "completed" }] + })), + images: None, + metadata: None, + }), + )) + .expect("other tool item completion should emit"); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type, "item.completed"); } #[test] diff --git a/lime-rs/crates/app-server/src/skills_watcher.rs b/lime-rs/crates/app-server/src/skills_watcher.rs new file mode 100644 index 000000000..9f9d9794b --- /dev/null +++ b/lime-rs/crates/app-server/src/skills_watcher.rs @@ -0,0 +1,296 @@ +use crate::{AppServerEventBridge, JsonRpcMessage}; +use app_server_protocol::protocol::v2::{ServerNotification, SkillsChangedNotification}; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10); + +pub(crate) struct SkillsWatcher { + _watcher: Arc>, + _watched_roots: Arc>>, + reconcile_task: tokio::task::JoinHandle<()>, +} + +impl Drop for SkillsWatcher { + fn drop(&mut self) { + self.reconcile_task.abort(); + } +} + +impl SkillsWatcher { + pub(crate) fn start_default(bridge: AppServerEventBridge) -> Option> { + Self::start( + bridge, + lime_skills::default_agent_skill_roots() + .into_iter() + .map(|root| root.path), + WATCHER_THROTTLE_INTERVAL, + ) + } + + fn start( + bridge: AppServerEventBridge, + roots: impl IntoIterator, + throttle_interval: Duration, + ) -> Option> { + let handle = match tokio::runtime::Handle::try_current() { + Ok(handle) => handle, + Err(error) => { + tracing::warn!(%error, "skills watcher skipped without Tokio runtime"); + return None; + } + }; + let roots = roots.into_iter().collect::>(); + if roots.is_empty() { + tracing::warn!("skills watcher has no default Skill roots"); + return None; + } + let last_notification = Arc::new(Mutex::new(None::)); + let callback_last_notification = last_notification.clone(); + let callback_bridge = bridge.clone(); + let callback_handle = handle.clone(); + let watcher = match notify::recommended_watcher(move |result: notify::Result| { + let event = match result { + Ok(event) if is_catalog_change(&event.kind) => event, + Ok(_) => return, + Err(error) => { + tracing::warn!(%error, "skills watcher received an error"); + return; + } + }; + if event.paths.is_empty() { + return; + } + notify_catalog_changed( + &callback_bridge, + &callback_handle, + &callback_last_notification, + throttle_interval, + ); + }) { + Ok(watcher) => watcher, + Err(error) => { + tracing::warn!(%error, "failed to initialize skills watcher"); + return None; + } + }; + let watcher = Arc::new(Mutex::new(watcher)); + let watched_roots = Arc::new(Mutex::new(HashSet::new())); + reconcile_watches(&watcher, &watched_roots, &roots); + + let reconcile_watcher = watcher.clone(); + let reconcile_watched_roots = watched_roots.clone(); + let reconcile_last_notification = last_notification.clone(); + let reconcile_handle = handle.clone(); + let reconcile_task = handle.spawn(async move { + let mut interval = tokio::time::interval(throttle_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + interval.tick().await; + loop { + interval.tick().await; + if reconcile_watches(&reconcile_watcher, &reconcile_watched_roots, &roots) { + notify_catalog_changed( + &bridge, + &reconcile_handle, + &reconcile_last_notification, + throttle_interval, + ); + } + } + }); + + Some(Arc::new(Self { + _watcher: watcher, + _watched_roots: watched_roots, + reconcile_task, + })) + } +} + +fn reconcile_watches( + watcher: &Mutex, + watched_roots: &Mutex>, + roots: &HashSet, +) -> bool { + let existing_roots = roots + .iter() + .filter(|root| root.is_dir()) + .map(|root| std::fs::canonicalize(root).unwrap_or_else(|_| root.clone())) + .collect::>(); + let mut watcher = watcher.lock().unwrap_or_else(|error| error.into_inner()); + let mut watched_roots = watched_roots + .lock() + .unwrap_or_else(|error| error.into_inner()); + let mut changed = false; + + for removed in watched_roots + .difference(&existing_roots) + .cloned() + .collect::>() + { + if let Err(error) = watcher.unwatch(&removed) { + tracing::debug!(path = %removed.display(), %error, "Skill root was already unwatched"); + } + watched_roots.remove(&removed); + changed = true; + } + for added in existing_roots + .difference(&watched_roots) + .cloned() + .collect::>() + { + match watcher.watch(&added, RecursiveMode::Recursive) { + Ok(()) => { + watched_roots.insert(added); + changed = true; + } + Err(error) => { + tracing::warn!(path = %added.display(), %error, "failed to watch Skill root"); + } + } + } + changed +} + +fn notify_catalog_changed( + bridge: &AppServerEventBridge, + handle: &tokio::runtime::Handle, + last_notification: &Mutex>, + throttle_interval: Duration, +) { + let now = Instant::now(); + let mut last = last_notification + .lock() + .unwrap_or_else(|error| error.into_inner()); + if last.is_some_and(|last| now.duration_since(last) < throttle_interval) { + return; + } + *last = Some(now); + drop(last); + + lime_skills::invalidate_agent_skill_snapshot_cache(); + let bridge = bridge.clone(); + handle.spawn(async move { + bridge + .broadcast_message(JsonRpcMessage::Notification( + ServerNotification::SkillsChanged(SkillsChangedNotification {}).into(), + )) + .await; + }); +} + +fn is_catalog_change(kind: &EventKind) -> bool { + matches!( + kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AppServer; + use std::fs; + + #[test] + fn only_catalog_mutations_trigger_invalidation() { + assert!(is_catalog_change(&EventKind::Create( + notify::event::CreateKind::File + ))); + assert!(is_catalog_change(&EventKind::Modify( + notify::event::ModifyKind::Data(notify::event::DataChange::Content) + ))); + assert!(is_catalog_change(&EventKind::Remove( + notify::event::RemoveKind::Folder + ))); + assert!(!is_catalog_change(&EventKind::Access( + notify::event::AccessKind::Read + ))); + } + + #[tokio::test] + async fn watched_skill_file_change_broadcasts_typed_invalidation() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("skills"); + let skill_dir = root.join("writer"); + fs::create_dir_all(&skill_dir).expect("create Skill directory"); + let skill_file = skill_dir.join("SKILL.md"); + fs::write(&skill_file, "---\nname: writer\ndescription: before\n---\n") + .expect("write Skill fixture"); + + let server = AppServer::new(); + let mut outbound = server.subscribe_outbound_messages(); + let _watcher = + SkillsWatcher::start(server.event_bridge(), [root], Duration::from_millis(20)) + .expect("start Skill watcher"); + fs::write(&skill_file, "---\nname: writer\ndescription: after\n---\n") + .expect("update Skill fixture"); + + let notification = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let message = outbound.recv().await.expect("watcher notification"); + if matches!( + &message, + JsonRpcMessage::Notification(notification) + if notification.method == "skills/changed" + ) { + break message; + } + } + }) + .await + .expect("wait for skills/changed"); + assert_eq!( + notification, + JsonRpcMessage::Notification( + ServerNotification::SkillsChanged(SkillsChangedNotification {}).into() + ) + ); + } + + #[tokio::test] + async fn skill_root_created_after_start_broadcasts_typed_invalidation() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("skills"); + let server = AppServer::new(); + let mut outbound = server.subscribe_outbound_messages(); + let _watcher = SkillsWatcher::start( + server.event_bridge(), + [root.clone()], + Duration::from_millis(20), + ) + .expect("start Skill watcher before root exists"); + + let skill_dir = root.join("writer"); + fs::create_dir_all(&skill_dir).expect("create Skill root and directory"); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: writer\ndescription: created later\n---\n", + ) + .expect("write Skill fixture"); + + let notification = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let message = outbound.recv().await.expect("watcher notification"); + if matches!( + &message, + JsonRpcMessage::Notification(notification) + if notification.method == "skills/changed" + ) { + break message; + } + } + }) + .await + .expect("wait for root creation skills/changed"); + assert_eq!( + notification, + JsonRpcMessage::Notification( + ServerNotification::SkillsChanged(SkillsChangedNotification {}).into() + ) + ); + } +} diff --git a/lime-rs/crates/mcp/src/events.rs b/lime-rs/crates/mcp/src/events.rs index 0c32ce62f..3ee4de699 100644 --- a/lime-rs/crates/mcp/src/events.rs +++ b/lime-rs/crates/mcp/src/events.rs @@ -41,9 +41,3 @@ pub struct McpResourceUpdatedPayload { pub server_name: String, pub uri: String, } - -/// OAuth 授权完成事件 -#[derive(Debug, Clone, Serialize)] -pub struct McpOAuthCompletedPayload { - pub server_name: String, -} diff --git a/lime-rs/crates/mcp/src/lib.rs b/lime-rs/crates/mcp/src/lib.rs index b97424bc0..adb9ec8fb 100644 --- a/lime-rs/crates/mcp/src/lib.rs +++ b/lime-rs/crates/mcp/src/lib.rs @@ -31,13 +31,12 @@ pub use elicitation::{ ElicitationRequestRouter, ElicitationResponse, ElicitationRouterError, }; pub use events::{ - McpOAuthCompletedPayload, McpResourceUpdatedPayload, McpResourcesUpdatedPayload, - McpServerErrorPayload, McpServerStartedPayload, McpServerStoppedPayload, - McpToolsUpdatedPayload, + McpResourceUpdatedPayload, McpResourcesUpdatedPayload, McpServerErrorPayload, + McpServerStartedPayload, McpServerStoppedPayload, McpToolsUpdatedPayload, }; pub use extension_surface::{build_runtime_extension_surface, runtime_extension_name}; pub use manager::{McpBridgeSnapshot, McpClientManager, McpRuntimeOwner, McpRuntimeServerSpec}; -pub use oauth::{McpOAuthLoginParams, McpOAuthLoginResponse, McpOAuthRegistry}; +pub use oauth::{McpOAuthLoginHandle, McpOAuthLoginParams, McpOAuthRegistry}; pub use tool_converter::ToolConverter; pub use types::{ McpContent, McpError, McpManagerState, McpPromptArgument, McpPromptDefinition, diff --git a/lime-rs/crates/mcp/src/manager.rs b/lime-rs/crates/mcp/src/manager.rs index cbb83a21e..a48cfd93a 100644 --- a/lime-rs/crates/mcp/src/manager.rs +++ b/lime-rs/crates/mcp/src/manager.rs @@ -38,7 +38,7 @@ use tracing::{debug, info, warn}; use crate::client::McpClientWrapper; use crate::environment::McpEnvironmentRegistry; use crate::events::*; -use crate::oauth::{McpOAuthLoginResponse, McpOAuthRegistry}; +use crate::oauth::{McpOAuthLoginHandle, McpOAuthRegistry}; use crate::types::*; mod lifecycle; @@ -373,9 +373,9 @@ impl McpClientManager { config: &McpServerConfig, scopes: Option>, timeout_secs: Option, - ) -> Result { + ) -> Result { self.oauth_registry - .start_login(name, config, scopes, timeout_secs, self.emitter.clone()) + .start_login(name, config, scopes, timeout_secs) .await } diff --git a/lime-rs/crates/mcp/src/oauth.rs b/lime-rs/crates/mcp/src/oauth.rs index 77749abee..36b9d6fd3 100644 --- a/lime-rs/crates/mcp/src/oauth.rs +++ b/lime-rs/crates/mcp/src/oauth.rs @@ -1,4 +1,3 @@ -use crate::events::{McpOAuthCompletedPayload, McpServerErrorPayload}; use crate::oauth_store::PersistentCredentialStore; use crate::streamable_http::build_default_headers; use crate::types::{McpError, McpServerConfig, McpServerTransport}; @@ -7,11 +6,12 @@ use axum::http::StatusCode; use axum::response::{Html, IntoResponse}; use axum::routing::get; use axum::Router; -use lime_core::DynEmitter; use rmcp::transport::auth::{AuthorizationManager, CredentialStore, OAuthState, StoredCredentials}; use serde::{Deserialize, Serialize}; +use std::future::Future; use std::net::SocketAddr; use std::path::PathBuf; +use std::pin::Pin; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{oneshot, Mutex}; @@ -28,11 +28,38 @@ pub struct McpOAuthLoginParams { pub timeout_secs: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub struct McpOAuthLoginResponse { +pub struct McpOAuthLoginHandle { pub authorization_url: String, pub state: String, + completion: Pin> + Send + 'static>>, +} + +impl std::fmt::Debug for McpOAuthLoginHandle { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("McpOAuthLoginHandle") + .field("authorization_url", &self.authorization_url) + .field("state", &self.state) + .finish_non_exhaustive() + } +} + +impl McpOAuthLoginHandle { + pub fn new( + authorization_url: impl Into, + state: impl Into, + completion: impl Future> + Send + 'static, + ) -> Self { + Self { + authorization_url: authorization_url.into(), + state: state.into(), + completion: Box::pin(completion), + } + } + + pub async fn wait(self) -> Result<(), McpError> { + self.completion.await + } } /// OAuth 凭据根只能由组合根注入。未注入时所有凭据操作 fail closed, @@ -59,8 +86,7 @@ impl McpOAuthRegistry { config: &McpServerConfig, scopes: Option>, timeout_secs: Option, - emitter: Option, - ) -> Result { + ) -> Result { let (url, bearer_token_env_var, http_headers, env_http_headers) = match &config.transport { McpServerTransport::StreamableHttp { url, @@ -133,36 +159,30 @@ impl McpOAuthRegistry { let wait_secs = timeout_secs .unwrap_or(DEFAULT_OAUTH_TIMEOUT_SECS) .clamp(30, 900); - let server_name_for_error = server_name.clone(); - let emitter_for_error = emitter.clone(); - tokio::spawn(async move { - if let Err(error) = complete_oauth_login( + let completion = async move { + let result = complete_oauth_login( auth_state, store, callback_rx, wait_secs, server_name.clone(), - emitter, ) - .await - { + .await; + if let Err(error) = &result { tracing::warn!( server_name = %server_name, error = %error, "MCP OAuth login did not complete" ); - emit_oauth_error( - emitter_for_error, - &server_name_for_error, - &error.to_string(), - ); } - }); + result + }; - Ok(McpOAuthLoginResponse { + Ok(McpOAuthLoginHandle::new( authorization_url, - state: "pending".to_string(), - }) + "pending", + completion, + )) } pub async fn authorized_manager_for( @@ -360,7 +380,6 @@ async fn complete_oauth_login( callback_rx: oneshot::Receiver, timeout_secs: u64, server_name: String, - emitter: Option, ) -> Result<(), McpError> { let login_timeout = Duration::from_secs(timeout_secs); let started_at = Instant::now(); @@ -410,49 +429,9 @@ async fn complete_oauth_login( }) .await .map_err(|_| McpError::Timeout)??; - emit_oauth_completed(emitter, &server_name); Ok(()) } -fn emit_oauth_completed(emitter: Option, server_name: &str) { - let Some(emitter) = emitter else { - return; - }; - let payload = McpOAuthCompletedPayload { - server_name: server_name.to_string(), - }; - let Ok(value) = serde_json::to_value(&payload) else { - return; - }; - if let Err(error) = emitter.emit_event("mcp:oauth_completed", &value) { - tracing::warn!( - server_name = %server_name, - error = %error, - "MCP OAuth completion event failed" - ); - } -} - -fn emit_oauth_error(emitter: Option, server_name: &str, error: &str) { - let Some(emitter) = emitter else { - return; - }; - let payload = McpServerErrorPayload { - server_name: server_name.to_string(), - error: error.to_string(), - }; - let Ok(value) = serde_json::to_value(&payload) else { - return; - }; - if let Err(error) = emitter.emit_event("mcp:server_error", &value) { - tracing::warn!( - server_name = %server_name, - error = %error, - "MCP OAuth error event failed" - ); - } -} - async fn discover_supported_scopes( url: &str, client: reqwest::Client, diff --git a/lime-rs/crates/mcp/src/oauth_tests.rs b/lime-rs/crates/mcp/src/oauth_tests.rs index 4f30b8ae7..2d142277a 100644 --- a/lime-rs/crates/mcp/src/oauth_tests.rs +++ b/lime-rs/crates/mcp/src/oauth_tests.rs @@ -6,28 +6,11 @@ use axum::http::{header::LOCATION, HeaderMap, StatusCode}; use axum::response::IntoResponse; use axum::routing::{get, post}; use axum::{Json, Router}; -use lime_core::EventEmit; -use serde_json::Value; use std::collections::HashMap; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use std::time::Duration; -#[derive(Clone, Default)] -struct RecordingEmitter { - events: Arc>>, -} - -impl EventEmit for RecordingEmitter { - fn emit_event(&self, event: &str, payload: &Value) -> Result<(), String> { - self.events - .lock() - .expect("events lock") - .push((event.to_string(), payload.clone())); - Ok(()) - } -} - fn oauth_config_with_explicit_client_id() -> McpServerConfig { McpServerConfig { transport: McpServerTransport::StreamableHttp { @@ -143,28 +126,6 @@ fn require_test_header(state: &TestOAuthProviderState, headers: &HeaderMap) { assert_eq!(actual, expected); } -async fn wait_for_event( - events: Arc>>, - event_name: &str, - server_name: &str, -) { - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let has_event = events - .lock() - .expect("events lock") - .iter() - .any(|(name, payload)| name == event_name && payload["server_name"] == server_name); - if has_event { - break; - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - }) - .await - .expect("event should be emitted"); -} - fn oauth_browser_client() -> reqwest::Client { reqwest::Client::builder() .no_proxy() @@ -300,37 +261,6 @@ fn merge_loopback_no_proxy_hosts_returns_none_when_complete() { ); } -#[test] -fn emit_oauth_completed_sends_server_event() { - let emitter = RecordingEmitter::default(); - let events = emitter.events.clone(); - - emit_oauth_completed(Some(DynEmitter::new(emitter)), "remote-docs"); - - let events = events.lock().expect("events lock"); - assert_eq!(events.len(), 1); - assert_eq!(events[0].0, "mcp:oauth_completed"); - assert_eq!(events[0].1["server_name"], "remote-docs"); -} - -#[test] -fn emit_oauth_error_sends_server_error_event() { - let emitter = RecordingEmitter::default(); - let events = emitter.events.clone(); - - emit_oauth_error( - Some(DynEmitter::new(emitter)), - "remote-docs", - "invalid_scope", - ); - - let events = events.lock().expect("events lock"); - assert_eq!(events.len(), 1); - assert_eq!(events[0].0, "mcp:server_error"); - assert_eq!(events[0].1["server_name"], "remote-docs"); - assert_eq!(events[0].1["error"], "invalid_scope"); -} - #[tokio::test] async fn credential_lookup_fails_closed_without_injected_store_root() { let registry = McpOAuthRegistry::new(); @@ -355,22 +285,13 @@ async fn start_login_completes_against_local_oauth_provider_and_persists_token() let temp = tempfile::tempdir().expect("tempdir"); let store_root = temp.path().join("oauth-store"); let registry = McpOAuthRegistry::new_in(&store_root); - let emitter = RecordingEmitter::default(); - let events = emitter.events.clone(); - let login = registry - .start_login( - "remote-docs", - &config, - None, - Some(30), - Some(DynEmitter::new(emitter)), - ) + .start_login("remote-docs", &config, None, Some(30)) .await .expect("start OAuth login"); let response = oauth_browser_client() - .get(login.authorization_url) + .get(login.authorization_url.clone()) .send() .await .expect("visit authorization URL"); @@ -379,29 +300,12 @@ async fn start_login_completes_against_local_oauth_provider_and_persists_token() "callback page should be successful" ); - let store = PersistentCredentialStore::new_in(&store_root, "remote-docs", &server_url); - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let has_credentials = store - .has_credentials() - .await - .expect("credential store should be readable"); - let has_event = events - .lock() - .expect("events lock") - .iter() - .any(|(name, payload)| { - name == "mcp:oauth_completed" && payload["server_name"] == "remote-docs" - }); - if has_credentials && has_event { - break; - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - }) - .await - .expect("OAuth completion should persist token and emit event"); + tokio::time::timeout(Duration::from_secs(5), login.wait()) + .await + .expect("OAuth completion should finish") + .expect("OAuth completion should succeed"); + let store = PersistentCredentialStore::new_in(&store_root, "remote-docs", &server_url); let credentials = store .load() .await @@ -427,12 +331,12 @@ async fn start_login_uses_discovered_scopes_when_config_has_no_scopes() { let registry = McpOAuthRegistry::new_in(temp.path().join("oauth-store")); let login = registry - .start_login("remote-docs", &config, None, Some(30), None) + .start_login("remote-docs", &config, None, Some(30)) .await .expect("start OAuth login"); let response = oauth_browser_client() - .get(login.authorization_url) + .get(login.authorization_url.clone()) .send() .await .expect("visit authorization URL"); @@ -450,6 +354,7 @@ async fn start_login_uses_discovered_scopes_when_config_has_no_scopes() { .and_then(|query| query.get("scope")) .expect("authorize URL should include discovered scopes"); assert_eq!(scope, "search.read search.write"); + login.wait().await.expect("OAuth completion should succeed"); } #[tokio::test] @@ -478,12 +383,12 @@ async fn start_login_applies_configured_headers_to_oauth_requests() { let registry = McpOAuthRegistry::new_in(&store_root); let login = registry - .start_login("remote-docs", &config, None, Some(30), None) + .start_login("remote-docs", &config, None, Some(30)) .await .expect("start OAuth login"); let response = oauth_browser_client() - .get(login.authorization_url) + .get(login.authorization_url.clone()) .send() .await .expect("visit authorization URL"); @@ -492,41 +397,27 @@ async fn start_login_applies_configured_headers_to_oauth_requests() { "callback page should be successful" ); + tokio::time::timeout(Duration::from_secs(5), login.wait()) + .await + .expect("OAuth completion should finish") + .expect("OAuth completion should succeed"); + let store = PersistentCredentialStore::new_in(&store_root, "remote-docs", &server_url); - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if store - .has_credentials() - .await - .expect("credential store should be readable") - { - break; - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - }) - .await - .expect("OAuth completion should persist token"); + assert!(store + .has_credentials() + .await + .expect("credential store should be readable")); } #[tokio::test] -async fn oauth_callback_provider_error_emits_server_error_without_timeout() { +async fn oauth_callback_provider_error_returns_without_timeout() { let provider = spawn_test_oauth_provider().await; let server_url = format!("{}/mcp", provider.base_url); let config = dynamic_oauth_config(server_url); let temp = tempfile::tempdir().expect("tempdir"); let registry = McpOAuthRegistry::new_in(temp.path().join("oauth-store")); - let emitter = RecordingEmitter::default(); - let events = emitter.events.clone(); - let login = registry - .start_login( - "remote-docs", - &config, - None, - Some(30), - Some(DynEmitter::new(emitter)), - ) + .start_login("remote-docs", &config, None, Some(30)) .await .expect("start OAuth login"); let mut callback_url = @@ -559,13 +450,10 @@ async fn oauth_callback_provider_error_emits_server_error_without_timeout() { .expect("visit callback URL"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); - wait_for_event(events.clone(), "mcp:server_error", "remote-docs").await; - let events = events.lock().expect("events lock"); - let error = events - .iter() - .find(|(name, _)| name == "mcp:server_error") - .map(|(_, payload)| payload["error"].as_str().unwrap_or_default()) - .expect("server error should be emitted"); - assert!(error.contains("invalid_scope")); - assert!(error.contains("scope rejected")); + let error = tokio::time::timeout(Duration::from_secs(5), login.wait()) + .await + .expect("OAuth failure should return without waiting for timeout") + .expect_err("provider error should fail OAuth completion"); + assert!(error.to_string().contains("invalid_scope")); + assert!(error.to_string().contains("scope rejected")); } diff --git a/lime-rs/crates/skills/src/agent_snapshot.rs b/lime-rs/crates/skills/src/agent_snapshot.rs index 14ffe142a..d79cc5d8c 100644 --- a/lime-rs/crates/skills/src/agent_snapshot.rs +++ b/lime-rs/crates/skills/src/agent_snapshot.rs @@ -210,6 +210,13 @@ fn snapshot_cache() -> &'static Mutex> { CACHE.get_or_init(|| Mutex::new(None)) } +pub fn invalidate_agent_skill_snapshot_cache() { + let mut guard = snapshot_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()); + *guard = None; +} + fn cached_snapshot(key: &str) -> Option { let guard = snapshot_cache() .lock() diff --git a/lime-rs/crates/skills/src/lib.rs b/lime-rs/crates/skills/src/lib.rs index 559024a5a..8501c05ab 100644 --- a/lime-rs/crates/skills/src/lib.rs +++ b/lime-rs/crates/skills/src/lib.rs @@ -47,9 +47,10 @@ pub use agent_selection::{ pub use agent_snapshot::{ agent_skill_roots_for_workspace, build_agent_skill_snapshot, build_agent_skill_snapshot_from_roots, build_agent_skill_snapshot_from_workspace, - default_agent_skill_roots, AgentSkillAuthority, AgentSkillDependencies, AgentSkillInterface, - AgentSkillMetadata, AgentSkillPolicy, AgentSkillRoot, AgentSkillScope, AgentSkillSnapshot, - AgentSkillSnapshotOptions, AgentSkillSource, AgentSkillToolDependency, + default_agent_skill_roots, invalidate_agent_skill_snapshot_cache, AgentSkillAuthority, + AgentSkillDependencies, AgentSkillInterface, AgentSkillMetadata, AgentSkillPolicy, + AgentSkillRoot, AgentSkillScope, AgentSkillSnapshot, AgentSkillSnapshotOptions, + AgentSkillSource, AgentSkillToolDependency, }; pub use execution_callback::{ events, ExecutionCallback, ExecutionCompletePayload, StepCompletePayload, StepErrorPayload, diff --git a/package.json b/package.json index 2fe1fbe43..f8402c6c8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "workspaces": [ "packages/*" ], - "version": "1.117.0", + "version": "1.118.0", "description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.", "keywords": [ "ai", @@ -220,6 +220,7 @@ "smoke:local-history-import-visual-audit": "node scripts/electron/local-history-import-visual-audit-smoke.mjs", "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: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", diff --git a/packages/agent-runtime-client/README.md b/packages/agent-runtime-client/README.md index d79576600..fe2b6e0dd 100644 --- a/packages/agent-runtime-client/README.md +++ b/packages/agent-runtime-client/README.md @@ -86,6 +86,7 @@ const thread = await runtime.readThread({ | `readThread(params, options?)` | `thread/read` | Thread read model | 供 projection hydration / repair 使用,建议 `includeTurns: true`。 | | `exportEvidence(params, options?)` | `evidence/export` | evidence export response | 缺 surface 时 fail closed,不伪造空 evidence。 | | `subscribeLifecycleEvents(listener)` | direct v2 notifications | unsubscribe handle | 分发 Thread / Turn / Item lifecycle 与 message delta。 | +| `subscribeSignalEvents(listener)` | direct v2 `error` | unsubscribe handle | 分发 retry/terminal error signal,不进入 lifecycle verifier。 | | `dispatchEvent(message)` | local event router | boolean | 用于现有 gateway 把 JSON-RPC notification 喂给 client。 | | `nextEvent(timeoutMs?)` | gateway event source | notification | 优先 gateway `nextEvent`,其次 `drainEvents`。 | @@ -108,7 +109,7 @@ client 只传递 lifecycle intent 和 facts,不维护 tool 状态机、subagen ## Browser-Safe Session Gateway -Renderer 宿主如果已经有自己的 App Server gateway,应从 browser-safe 子路径导入 session gateway 适配器,避免把根入口中的 Node 侧 sidecar / stdio client 打进前端包。该适配器仍返回标准 `AgentRuntimeClient`,覆盖 turn lifecycle、`readThread`、`exportEvidence`、`subscribeLifecycleEvents`、`dispatchEvent` 和 `nextEvent`;宿主缺少 evidence 或 event source 时会 fail closed,不会静默回退 mock。 +Renderer 宿主如果已经有自己的 App Server gateway,应从 browser-safe 子路径导入 session gateway 适配器,避免把根入口中的 Node 侧 sidecar / stdio client 打进前端包。该适配器仍返回标准 `AgentRuntimeClient`,覆盖 turn lifecycle、`readThread`、`exportEvidence`、`subscribeLifecycleEvents`、`subscribeSignalEvents`、`dispatchEvent` 和 `nextEvent`;宿主缺少 evidence 或 event source 时会 fail closed,不会静默回退 mock。 `createAgentRuntimeClientFromSessionGateway(...)` 只适配现有 session gateway, 不会创建第二套 transport、lifecycle state 或兼容协议。 @@ -154,7 +155,7 @@ const runtime: AgentRuntimeLifecycleClient = | Root entry | 适合 Node / host owner,复用 `@limecloud/app-server-client` 的 `AppServerConnection`。 | | `./sessionGateway` | browser-safe,适合 renderer 复用已有 App Server gateway。 | | Electron / Desktop Host | 只能由宿主 gateway 封装,不能在本包直接 import bridge helper。 | -| Event source | `nextEvent(timeoutMs?)` 或 `drainEvents(limit?)`,只接受 direct v2 Thread / Turn / Item notification 与 message delta。 | +| Event source | `nextEvent(timeoutMs?)` 或 `drainEvents(limit?)`,接受 direct v2 lifecycle notification、message delta 与 typed `error` signal。 | | Mock / fixture | 只允许测试显式传入,不允许 production transport fallback。 | 如果 gateway 方法来自 class instance,必须在宿主里包成闭包,避免丢失 `this`。本包不会替宿主绑定私有 client 实例。 @@ -200,7 +201,7 @@ Product App business context 最小 runtime client conformance: - lifecycle:`startTurn -> steerTurn / readThread -> cancelTurn / respondAction` 均委托 gateway。 -- events:`subscribeLifecycleEvents`、`dispatchEvent`、`nextEvent` 只把 direct v2 Thread / Turn / Item 与 message delta 送入 lifecycle pipeline;raw channel 一律拒绝。 +- events:`subscribeLifecycleEvents` 只接收 direct v2 Thread / Turn / Item 与 message delta;`subscribeSignalEvents` 独立接收 typed `error`,不进入 lifecycle pipeline;raw channel 一律拒绝。 - evidence:`exportEvidence` 有实现时委托,没有实现时 fail closed。 - errors:transport error 原样传播,不切 mock。 - bundle:`@limecloud/agent-runtime-client/sessionGateway` dist 不包含 Node builtin 或 sidecar/stdio 模块;只允许依赖 browser-safe App Server entry。 diff --git a/packages/agent-runtime-client/src/index.ts b/packages/agent-runtime-client/src/index.ts index 29900c940..d33e452fb 100644 --- a/packages/agent-runtime-client/src/index.ts +++ b/packages/agent-runtime-client/src/index.ts @@ -5,6 +5,8 @@ export { type AgentRuntimeLifecycleEventListener, type AgentRuntimeLifecycleNotification, type AgentRuntimeNotification, + type AgentRuntimeSignalEventListener, + type AgentRuntimeSignalNotification, type AgentSessionActionRespondParams, type AgentSessionActionRespondResponse, type ThreadReadParams, diff --git a/packages/agent-runtime-client/src/runtimeClient.ts b/packages/agent-runtime-client/src/runtimeClient.ts index ee93f45d5..fde1e9f6a 100644 --- a/packages/agent-runtime-client/src/runtimeClient.ts +++ b/packages/agent-runtime-client/src/runtimeClient.ts @@ -6,6 +6,8 @@ import { type AgentRuntimeLifecycleEventListener, type AgentRuntimeLifecycleNotification, type AgentRuntimeNotification, + type AgentRuntimeSignalEventListener, + type AgentRuntimeSignalNotification, type JsonRpcError, type AgentSessionActionRespondParams, type AgentSessionActionRespondResponse, @@ -31,7 +33,8 @@ import { type JsonRpcMessage, type RequestId, type StructuredOutputContract, - serverNotification, + agentRuntimeLifecycleNotification, + agentRuntimeSignalNotification, } from "@limecloud/app-server-client"; export type { StructuredOutputContract }; @@ -57,6 +60,7 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient { readonly connection: AppServerConnection; readonly #base: BaseAppServerAgentRuntimeClient; readonly #lifecycleListeners = new Set(); + readonly #signalListeners = new Set(); readonly #eventPipeline: AgentRuntimeEventPipeline; readonly #pendingNextEvents: AgentRuntimeNotification[] = []; @@ -158,8 +162,24 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient { }; } + subscribeSignalEvents( + listener: AgentRuntimeSignalEventListener, + ): AgentRuntimeClientSubscription { + this.#signalListeners.add(listener); + return { + unsubscribe: () => { + this.#signalListeners.delete(listener); + }, + }; + } + async dispatchEvent(message: JsonRpcMessage): Promise { - const lifecycle = serverNotification(message); + const signal = agentRuntimeSignalNotification(message); + if (signal) { + await this.#dispatchSignal(signal); + return true; + } + const lifecycle = agentRuntimeLifecycleNotification(message); if (lifecycle) { const result = await this.#dispatchLifecycle(lifecycle); return result.accepted; @@ -167,6 +187,14 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient { return false; } + async #dispatchSignal( + notification: AgentRuntimeSignalNotification, + ): Promise { + for (const listener of this.#signalListeners) { + await listener(notification, notification); + } + } + async #dispatchLifecycle( notification: AgentRuntimeLifecycleNotification, ): Promise { @@ -189,7 +217,12 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient { return pending; } const notification = await this.connection.nextNotification(timeoutMs); - const lifecycle = serverNotification(notification); + const signal = agentRuntimeSignalNotification(notification); + if (signal) { + await this.#dispatchSignal(signal); + return signal; + } + const lifecycle = agentRuntimeLifecycleNotification(notification); if (lifecycle) { const result = await this.#dispatchLifecycle(lifecycle); if (result.accepted) { diff --git a/packages/agent-runtime-client/src/sessionGateway.ts b/packages/agent-runtime-client/src/sessionGateway.ts index d1a968c99..a3097b119 100644 --- a/packages/agent-runtime-client/src/sessionGateway.ts +++ b/packages/agent-runtime-client/src/sessionGateway.ts @@ -1,9 +1,12 @@ import { - serverNotification, + agentRuntimeLifecycleNotification, + agentRuntimeSignalNotification, AgentRuntimeClient, AgentRuntimeClientSubscription, AgentRuntimeLifecycleEventListener, AgentRuntimeNotification, + AgentRuntimeSignalEventListener, + AgentRuntimeSignalNotification, AgentSessionActionRespondParams, AgentSessionActionRespondResponse, ThreadReadParams, @@ -169,6 +172,9 @@ export function createAgentRuntimeClientFromSessionGateway( subscribeLifecycleEvents(listener) { return eventRouter.subscribeLifecycle(listener); }, + subscribeSignalEvents(listener) { + return eventRouter.subscribeSignal(listener); + }, async dispatchEvent(message) { const result = await eventRouter.dispatch(message); return result.accepted; @@ -264,6 +270,7 @@ async function nextDrainedAgentRuntimeEvent( class AgentRuntimeGatewayEventRouter { readonly #lifecycleListeners = new Set(); + readonly #signalListeners = new Set(); readonly #eventPipeline: AgentRuntimeEventPipeline; readonly #pendingNextEvents: AgentRuntimeNotification[] = []; @@ -287,10 +294,28 @@ class AgentRuntimeGatewayEventRouter { }; } + subscribeSignal( + listener: AgentRuntimeSignalEventListener, + ): AgentRuntimeClientSubscription { + this.#signalListeners.add(listener); + return { + unsubscribe: () => { + this.#signalListeners.delete(listener); + }, + }; + } + async dispatch( message: JsonRpcMessage, ): Promise { - const lifecycle = serverNotification(message); + const signal = agentRuntimeSignalNotification(message); + if (signal) { + for (const listener of this.#signalListeners) { + await listener(signal, signal); + } + return acceptedSignalResult(signal); + } + const lifecycle = agentRuntimeLifecycleNotification(message); if (!lifecycle) { return { accepted: false, reason: "dropped" }; } @@ -321,4 +346,22 @@ class AgentRuntimeGatewayEventRouter { } } -type AgentRuntimeGatewayDispatchResult = AgentRuntimeEventPipelineResult; +type AgentRuntimeSignalDispatchResult = { + accepted: true; + notification: AgentRuntimeSignalNotification; + notifications: [AgentRuntimeSignalNotification]; +}; + +type AgentRuntimeGatewayDispatchResult = + | AgentRuntimeEventPipelineResult + | AgentRuntimeSignalDispatchResult; + +function acceptedSignalResult( + signal: AgentRuntimeSignalNotification, +): AgentRuntimeSignalDispatchResult { + return { + accepted: true, + notification: signal, + notifications: [signal], + }; +} diff --git a/packages/agent-runtime-client/tests/client.test.mjs b/packages/agent-runtime-client/tests/client.test.mjs index c082d4540..1f6308de8 100644 --- a/packages/agent-runtime-client/tests/client.test.mjs +++ b/packages/agent-runtime-client/tests/client.test.mjs @@ -263,13 +263,20 @@ test("malformed direct lifecycle notifications fail closed with explicit mapper test("root client dispatches direct lifecycle and delta only to lifecycle listeners", async () => { const runtime = createRuntimeClient(); const lifecycle = []; + const signals = []; runtime.subscribeLifecycleEvents((event) => lifecycle.push(event)); + runtime.subscribeSignalEvents((event) => signals.push(event)); for (const notification of directToolLifecycle()) { assert.equal(await runtime.dispatchEvent(notification), true); } const delta = agentMessageDeltaNotification(); assert.equal(await runtime.dispatchEvent(delta), true); + assert.equal(await runtime.dispatchEvent(errorNotification(true)), true); + assert.equal( + await runtime.dispatchEvent(turnPlanUpdatedNotification()), + true, + ); assert.equal( await runtime.dispatchEvent(rawNotification("turn.completed")), false, @@ -285,6 +292,10 @@ test("root client dispatches direct lifecycle and delta only to lifecycle listen "item/agentMessage/delta", ], ); + assert.deepEqual(signals, [ + errorNotification(true), + turnPlanUpdatedNotification(), + ]); }); test("session gateway dispatches direct lifecycle and rejects wrapper lifecycle", async () => { @@ -292,7 +303,9 @@ test("session gateway dispatches direct lifecycle and rejects wrapper lifecycle" createMinimalSessionGateway(), ); const lifecycle = []; + const signals = []; runtime.subscribeLifecycleEvents((event) => lifecycle.push(event)); + runtime.subscribeSignalEvents((event) => signals.push(event)); assert.equal( await runtime.dispatchEvent(turnNotification("inProgress")), @@ -307,6 +320,11 @@ test("session gateway dispatches direct lifecycle and rejects wrapper lifecycle" await runtime.dispatchEvent(mcpToolCallProgressNotification()), true, ); + assert.equal(await runtime.dispatchEvent(errorNotification(false)), true); + assert.equal( + await runtime.dispatchEvent(turnPlanUpdatedNotification()), + true, + ); assert.equal( await runtime.dispatchEvent(rawNotification("turn.started")), false, @@ -321,6 +339,10 @@ test("session gateway dispatches direct lifecycle and rejects wrapper lifecycle" "item/mcpToolCall/progress", ], ); + assert.deepEqual(signals, [ + errorNotification(false), + turnPlanUpdatedNotification(), + ]); }); test("disabling sequence verification does not restore raw lifecycle", async () => { @@ -369,6 +391,28 @@ test("nextEvent consumes direct notifications from gateway sources", async () => const drainedEvent = await drainedRuntime.nextEvent(); assert.equal(drainedEvent.method, "turn/started"); + + const directSignal = errorNotification(true); + const signalRuntime = createAgentRuntimeClientFromSessionGateway({ + ...createMinimalSessionGateway(), + async nextEvent() { + return directSignal; + }, + }); + const signals = []; + signalRuntime.subscribeSignalEvents((event) => signals.push(event)); + + const signalEvent = await signalRuntime.nextEvent(); + assert.equal(signalEvent.method, "error"); + assert.deepEqual(signals, [directSignal]); + + const drainedSignalRuntime = createAgentRuntimeClientFromSessionGateway({ + ...createMinimalSessionGateway(), + async drainEvents() { + return [{ method: "log/list", params: {} }, errorNotification(false)]; + }, + }); + assert.equal((await drainedSignalRuntime.nextEvent()).method, "error"); }); test("sequence verifier fails closed for orphan direct tool completion", async () => { @@ -788,6 +832,35 @@ function agentMessageDeltaNotification(overrides = {}) { }; } +function errorNotification(willRetry, overrides = {}) { + return { + method: "error", + params: { + error: { message: "provider stream reconnecting" }, + threadId: "thread-1", + turnId: "turn-1", + willRetry, + ...overrides, + }, + }; +} + +function turnPlanUpdatedNotification(overrides = {}) { + return { + method: "turn/plan/updated", + params: { + explanation: "继续执行", + plan: [ + { step: "读现状", status: "completed" }, + { step: "补主链", status: "inProgress" }, + ], + threadId: "thread-1", + turnId: "turn-1", + ...overrides, + }, + }; +} + function commandOutputDeltaNotification(overrides = {}) { return { method: "item/commandExecution/outputDelta", diff --git a/packages/app-server-client/src/agent-runtime.ts b/packages/app-server-client/src/agent-runtime.ts index 50abf0b18..aa3140ebb 100644 --- a/packages/app-server-client/src/agent-runtime.ts +++ b/packages/app-server-client/src/agent-runtime.ts @@ -50,13 +50,25 @@ export type AgentRuntimeLifecycleNotification = Extract< } >; -export type AgentRuntimeNotification = AgentRuntimeLifecycleNotification; +export type AgentRuntimeSignalNotification = Extract< + ServerNotification, + { method: "error" | "turn/plan/updated" } +>; + +export type AgentRuntimeNotification = + | AgentRuntimeLifecycleNotification + | AgentRuntimeSignalNotification; export type AgentRuntimeLifecycleEventListener = ( event: AgentRuntimeLifecycleNotification, notification: AgentRuntimeLifecycleNotification, ) => void | Promise; +export type AgentRuntimeSignalEventListener = ( + event: AgentRuntimeSignalNotification, + notification: AgentRuntimeSignalNotification, +) => void | Promise; + export type AgentRuntimeClientOptions = { request?: AppServerRequestOptions; }; @@ -109,12 +121,16 @@ export interface AgentRuntimeClient { subscribeLifecycleEvents( listener: AgentRuntimeLifecycleEventListener, ): AgentRuntimeClientSubscription; + subscribeSignalEvents( + listener: AgentRuntimeSignalEventListener, + ): AgentRuntimeClientSubscription; dispatchEvent(message: JsonRpcMessage): Promise; nextEvent(timeoutMs?: number): Promise; } export class AppServerAgentEventRouter { #lifecycleListeners = new Set(); + #signalListeners = new Set(); subscribeLifecycle(listener: AgentRuntimeLifecycleEventListener): () => void { this.#lifecycleListeners.add(listener); @@ -123,7 +139,21 @@ export class AppServerAgentEventRouter { }; } + subscribeSignal(listener: AgentRuntimeSignalEventListener): () => void { + this.#signalListeners.add(listener); + return () => { + this.#signalListeners.delete(listener); + }; + } + async dispatch(message: JsonRpcMessage): Promise { + const signal = agentRuntimeSignalNotification(message); + if (signal) { + for (const listener of this.#signalListeners) { + await listener(signal, signal); + } + return true; + } const lifecycle = agentRuntimeLifecycleNotification(message); if (lifecycle) { for (const listener of this.#lifecycleListeners) { @@ -254,6 +284,13 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient { return { unsubscribe }; } + subscribeSignalEvents( + listener: AgentRuntimeSignalEventListener, + ): AgentRuntimeClientSubscription { + const unsubscribe = this.eventRouter.subscribeSignal(listener); + return { unsubscribe }; + } + async dispatchEvent(message: JsonRpcMessage): Promise { return await this.eventRouter.dispatch(message); } @@ -261,6 +298,11 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient { async nextEvent(timeoutMs?: number): Promise { for (;;) { const notification = await this.connection.nextNotification(timeoutMs); + const signal = agentRuntimeSignalNotification(notification); + if (signal) { + await this.dispatchEvent(signal); + return signal; + } const lifecycle = agentRuntimeLifecycleNotification(notification); if (lifecycle) { await this.dispatchEvent(lifecycle); @@ -273,7 +315,28 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient { export function agentRuntimeLifecycleNotification( message: JsonRpcMessage, ): AgentRuntimeLifecycleNotification | undefined { - return serverNotification(message); + const notification = serverNotification(message); + return notification && !isAgentRuntimeSignalNotification(notification) + ? notification + : undefined; +} + +export function agentRuntimeSignalNotification( + message: JsonRpcMessage, +): AgentRuntimeSignalNotification | undefined { + const notification = serverNotification(message); + return notification && isAgentRuntimeSignalNotification(notification) + ? notification + : undefined; +} + +function isAgentRuntimeSignalNotification( + notification: ServerNotification, +): notification is AgentRuntimeSignalNotification { + return ( + notification.method === "error" || + notification.method === "turn/plan/updated" + ); } export function createAgentRuntimeClient( diff --git a/packages/app-server-client/src/generated/protocol-types.ts b/packages/app-server-client/src/generated/protocol-types.ts index ef401904d..07fc09b58 100644 --- a/packages/app-server-client/src/generated/protocol-types.ts +++ b/packages/app-server-client/src/generated/protocol-types.ts @@ -77,6 +77,7 @@ export const METHOD_DIAGNOSTICS_TRACE_READ = "diagnostics/trace/read"; export const METHOD_DIAGNOSTICS_WINDOWS_STARTUP_READ = "diagnostics/windowsStartup/read"; export const METHOD_DISCORD_CHANNEL_PROBE = "discordChannel/probe"; +export const METHOD_ERROR = "error"; export const METHOD_EVIDENCE_EXPORT = "evidence/export"; export const METHOD_EXECUTION_PROCESS_DRAIN_OUTPUT = "executionProcess/drainOutput"; @@ -176,6 +177,8 @@ export const METHOD_MCP_SERVER_ENABLED_SET = "mcpServer/enabled/set"; export const METHOD_MCP_SERVER_IMPORT_FROM_APP = "mcpServer/importFromApp"; export const METHOD_MCP_SERVER_LIST = "mcpServer/list"; export const METHOD_MCP_SERVER_OAUTH_LOGIN = "mcpServer/oauth/login"; +export const METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED = + "mcpServer/oauthLogin/completed"; export const METHOD_MCP_SERVER_START = "mcpServer/start"; export const METHOD_MCP_SERVER_STOP = "mcpServer/stop"; export const METHOD_MCP_SERVER_SYNC_ALL_TO_LIVE = "mcpServer/syncAllToLive"; @@ -310,6 +313,7 @@ export const METHOD_SKILL_REMOTE_INSPECT = "skillRemote/inspect"; export const METHOD_SKILL_REPOSITORY_DELETE = "skillRepository/delete"; export const METHOD_SKILL_REPOSITORY_LIST = "skillRepository/list"; export const METHOD_SKILL_REPOSITORY_SAVE = "skillRepository/save"; +export const METHOD_SKILLS_CHANGED = "skills/changed"; export const METHOD_SOUL_STYLE_PACK_INSTALL = "soulStylePack/install"; export const METHOD_SOUL_STYLE_PACK_LIST = "soulStylePack/list"; export const METHOD_SOUL_STYLE_PACK_STATUS_SET = "soulStylePack/status/set"; @@ -364,6 +368,7 @@ export const METHOD_THREAD_UNARCHIVED = "thread/unarchived"; export const METHOD_THREAD_UNSUBSCRIBE = "thread/unsubscribe"; export const METHOD_TURN_COMPLETED = "turn/completed"; export const METHOD_TURN_INTERRUPT = "turn/interrupt"; +export const METHOD_TURN_PLAN_UPDATED = "turn/plan/updated"; export const METHOD_TURN_START = "turn/start"; export const METHOD_TURN_STARTED = "turn/started"; export const METHOD_TURN_STEER = "turn/steer"; @@ -639,6 +644,10 @@ export const GENERATED_APP_SERVER_METHODS = [ kind: "request", method: "discordChannel/probe", }, + { + kind: "notification", + method: "error", + }, { kind: "request", method: "evidence/export", @@ -943,6 +952,10 @@ export const GENERATED_APP_SERVER_METHODS = [ kind: "request", method: "mcpServer/oauth/login", }, + { + kind: "notification", + method: "mcpServer/oauthLogin/completed", + }, { kind: "request", method: "mcpServer/start", @@ -1415,6 +1428,10 @@ export const GENERATED_APP_SERVER_METHODS = [ kind: "request", method: "skillRepository/save", }, + { + kind: "notification", + method: "skills/changed", + }, { kind: "request", method: "soulStylePack/install", @@ -1607,6 +1624,10 @@ export const GENERATED_APP_SERVER_METHODS = [ kind: "request", method: "turn/interrupt", }, + { + kind: "notification", + method: "turn/plan/updated", + }, { kind: "request", method: "turn/start", @@ -4604,6 +4625,44 @@ export interface ClientResponse { result: unknown; } +export type CodexErrorInfo = + | "badRequest" + | "contextWindowExceeded" + | "cyberPolicy" + | "internalServerError" + | "other" + | "sandboxError" + | "serverOverloaded" + | "sessionBudgetExceeded" + | "threadRollbackFailed" + | "unauthorized" + | "usageLimitExceeded" + | { + httpConnectionFailed: { + httpStatusCode?: number | null; + }; + } + | { + responseStreamConnectionFailed: { + httpStatusCode?: number | null; + }; + } + | { + responseStreamDisconnected: { + httpStatusCode?: number | null; + }; + } + | { + responseTooManyFailedAttempts: { + httpStatusCode?: number | null; + }; + } + | { + activeTurnNotSteerable: { + turnKind: NonSteerableTurnKind; + }; + }; + export interface CollabAgentState { message?: null | string; status: CollabAgentStatus; @@ -5145,6 +5204,13 @@ export type EndpointKind = | "openai_compatible" | "provider_base_url"; +export interface ErrorNotification { + error: TurnError; + threadId: string; + turnId: string; + willRetry: boolean; +} + export interface EvidenceExportParams { includeArtifacts?: boolean | null; includeEvents?: boolean | null; @@ -5948,6 +6014,13 @@ export interface McpServerListResponse { servers?: unknown[]; } +export interface McpServerOauthLoginCompletedNotification { + error?: null | string; + name: string; + success: boolean; + threadId: null | string; +} + export interface McpServerOauthLoginParams { name: string; scopes?: string[] | null; @@ -6897,6 +6970,8 @@ export interface ModelVerificationNotification { verifications: ModelVerification[]; } +export type NonSteerableTurnKind = "compact" | "review"; + export interface OpenDeepLinkPayload { action?: null | string; kind: string; @@ -7724,6 +7799,18 @@ export type ServerNotification = method: "warning"; params: WarningNotification; } + | { + method: "error"; + params: ErrorNotification; + } + | { + method: "skills/changed"; + params: SkillsChangedNotification; + } + | { + method: "mcpServer/oauthLogin/completed"; + params: McpServerOauthLoginCompletedNotification; + } | { method: "thread/started"; params: ThreadStartedNotification; @@ -7760,6 +7847,10 @@ export type ServerNotification = method: "turn/completed"; params: TurnCompletedNotification; } + | { + method: "turn/plan/updated"; + params: TurnPlanUpdatedNotification; + } | { method: "item/started"; params: ItemStartedNotification; @@ -8204,6 +8295,8 @@ export interface SkillWorkflowStep { name: string; } +export type SkillsChangedNotification = Record; + export interface SleepItem { durationMs?: number | null; id: string; @@ -9096,7 +9189,7 @@ export interface TurnEnvironmentParams { export interface TurnError { additionalDetails?: null | string; - errorInfo?: unknown; + codexErrorInfo?: CodexErrorInfo | null; message: string; } @@ -9109,6 +9202,20 @@ export type TurnInterruptResponse = Record; export type TurnItemsView = "full" | "notLoaded" | "summary"; +export interface TurnPlanStep { + status: TurnPlanStepStatus; + step: string; +} + +export type TurnPlanStepStatus = "completed" | "inProgress" | "pending"; + +export interface TurnPlanUpdatedNotification { + explanation?: null | string; + plan: TurnPlanStep[]; + threadId: string; + turnId: string; +} + export interface TurnStartParams { additionalContext?: null | Record; approvalPolicy?: unknown; diff --git a/packages/app-server-client/src/server-notifications.ts b/packages/app-server-client/src/server-notifications.ts index ade9c7b16..9d7b2756a 100644 --- a/packages/app-server-client/src/server-notifications.ts +++ b/packages/app-server-client/src/server-notifications.ts @@ -8,6 +8,8 @@ export type RuntimeServerNotification = Extract< ServerNotification, { method: + | "error" + | "turn/plan/updated" | "thread/started" | "turn/started" | "turn/completed" @@ -30,6 +32,16 @@ export type ModelListUpdatedServerNotification = Extract< { method: "model/list/updated" } >; +export type SkillsChangedServerNotification = Extract< + ServerNotification, + { method: "skills/changed" } +>; + +export type McpServerOauthLoginCompletedServerNotification = Extract< + ServerNotification, + { method: "mcpServer/oauthLogin/completed" } +>; + export type ServerNotificationFor< Method extends RuntimeServerNotification["method"], > = Extract; @@ -42,6 +54,14 @@ export function serverNotification( } switch (message.method) { + case "error": + return hasErrorNotification(message.params) + ? (message as ServerNotificationFor<"error">) + : undefined; + case "turn/plan/updated": + return hasTurnPlanUpdatedNotification(message.params) + ? (message as ServerNotificationFor<"turn/plan/updated">) + : undefined; case "thread/started": return hasEntityId(record(message.params)?.thread) ? (message as ServerNotificationFor<"thread/started">) @@ -146,12 +166,65 @@ export function isModelListUpdatedNotification( return modelListUpdatedServerNotification(message) !== undefined; } +export function skillsChangedServerNotification( + message: JsonRpcMessage, +): SkillsChangedServerNotification | undefined { + if (!isJsonRpcNotification(message) || message.method !== "skills/changed") { + return undefined; + } + const params = record(message.params); + if (!params || Object.keys(params).length !== 0) { + return undefined; + } + return message as SkillsChangedServerNotification; +} + +export function isSkillsChangedNotification( + message: JsonRpcMessage, +): message is SkillsChangedServerNotification { + return skillsChangedServerNotification(message) !== undefined; +} + +export function mcpServerOauthLoginCompletedServerNotification( + message: JsonRpcMessage, +): McpServerOauthLoginCompletedServerNotification | undefined { + if ( + !isJsonRpcNotification(message) || + message.method !== "mcpServer/oauthLogin/completed" + ) { + return undefined; + } + const params = record(message.params); + if ( + !hasOnlyKeys(params, ["error", "name", "success", "threadId"]) || + !hasString(params, "name") || + typeof params?.success !== "boolean" || + !hasRequiredNullableString(params, "threadId") || + !hasOptionalString(params, "error") + ) { + return undefined; + } + return message as McpServerOauthLoginCompletedServerNotification; +} + export function isThreadStartedNotification( message: JsonRpcMessage, ): message is ServerNotificationFor<"thread/started"> { return serverNotification(message)?.method === "thread/started"; } +export function isErrorNotification( + message: JsonRpcMessage, +): message is ServerNotificationFor<"error"> { + return serverNotification(message)?.method === "error"; +} + +export function isTurnPlanUpdatedNotification( + message: JsonRpcMessage, +): message is ServerNotificationFor<"turn/plan/updated"> { + return serverNotification(message)?.method === "turn/plan/updated"; +} + export function isTurnStartedNotification( message: JsonRpcMessage, ): message is ServerNotificationFor<"turn/started"> { @@ -246,6 +319,133 @@ function hasTurnNotification(value: unknown, statuses: string[]): boolean { ); } +function hasErrorNotification(value: unknown): boolean { + const params = record(value); + const error = record(params?.error); + const additionalDetails = error?.additionalDetails; + const codexErrorInfo = error?.codexErrorInfo; + return ( + hasOnlyKeys(params, ["error", "threadId", "turnId", "willRetry"]) && + hasOnlyKeys(error, ["additionalDetails", "codexErrorInfo", "message"]) && + hasString(params, "threadId") && + hasString(params, "turnId") && + hasString(error, "message") && + typeof params?.willRetry === "boolean" && + hasCodexErrorInfo(codexErrorInfo) && + (additionalDetails === undefined || + additionalDetails === null || + typeof additionalDetails === "string") + ); +} + +function hasTurnPlanUpdatedNotification(value: unknown): boolean { + const params = record(value); + if ( + !hasOnlyKeys(params, ["explanation", "plan", "threadId", "turnId"]) || + !hasString(params, "threadId") || + !hasString(params, "turnId") || + !Array.isArray(params?.plan) || + (params.explanation !== undefined && + params.explanation !== null && + typeof params.explanation !== "string") + ) { + return false; + } + return params.plan.every((value) => { + const step = record(value); + return ( + hasOnlyKeys(step, ["status", "step"]) && + hasString(step, "step") && + (step?.status === "pending" || + step?.status === "inProgress" || + step?.status === "completed") + ); + }); +} + +function hasOnlyKeys( + value: Record | undefined, + allowedKeys: string[], +): boolean { + return Boolean( + value && Object.keys(value).every((key) => allowedKeys.includes(key)), + ); +} + +function hasRequiredNullableString( + value: Record | undefined, + key: string, +): boolean { + return Boolean( + value && + Object.prototype.hasOwnProperty.call(value, key) && + (value[key] === null || hasString(value, key)), + ); +} + +function hasOptionalString( + value: Record | undefined, + key: string, +): boolean { + return Boolean( + value && + (!Object.prototype.hasOwnProperty.call(value, key) || + typeof value[key] === "string"), + ); +} + +function hasCodexErrorInfo(value: unknown): boolean { + if (value === undefined || value === null) { + return true; + } + if (typeof value === "string") { + return [ + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded", + ].includes(value); + } + + const variant = record(value); + if (!variant || Object.keys(variant).length !== 1) { + return false; + } + const variantName = Object.keys(variant)[0]; + const details = record(variant[variantName]); + if (!details) { + return false; + } + if (variantName === "activeTurnNotSteerable") { + return details.turnKind === "review" || details.turnKind === "compact"; + } + if ( + ![ + "httpConnectionFailed", + "responseStreamConnectionFailed", + "responseStreamDisconnected", + "responseTooManyFailedAttempts", + ].includes(variantName) + ) { + return false; + } + const status = details.httpStatusCode; + return ( + status === undefined || + status === null || + (Number.isInteger(status) && + (status as number) >= 0 && + (status as number) <= 65_535) + ); +} + function hasItemNotification( value: unknown, timestampKey: "startedAtMs" | "completedAtMs", diff --git a/packages/app-server-client/tests/client.test.mjs b/packages/app-server-client/tests/client.test.mjs index ee81187e8..bf5c013e7 100644 --- a/packages/app-server-client/tests/client.test.mjs +++ b/packages/app-server-client/tests/client.test.mjs @@ -3479,6 +3479,37 @@ test("agent runtime client facade subscribes to direct lifecycle notifications", assert.equal(received[0].message.method, "turn/started"); }); +test("agent runtime client facade returns typed errors through the signal channel", async () => { + const notification = { + method: "error", + params: { + error: { message: "provider stream reconnecting" }, + threadId: "thread_external", + turnId: "turn_external", + willRetry: true, + }, + }; + const runtime = new AppServerAgentRuntimeClient( + new AppServerConnection({ + send() {}, + async nextMessage() { + return notification; + }, + }), + ); + const signals = []; + const subscription = runtime.subscribeSignalEvents((event, message) => { + signals.push({ event, message }); + }); + + assert.equal(await runtime.dispatchEvent(notification), true); + assert.equal((await runtime.nextEvent()).method, "error"); + subscription.unsubscribe(); + assert.equal(signals.length, 2); + assert.equal(signals[0].event.params.willRetry, true); + assert.equal(signals[0].message.method, "error"); +}); + test("agent runtime client facade subscribes to direct item lifecycle", async () => { const notification = { method: "item/started", @@ -4628,6 +4659,53 @@ test("routes direct lifecycle notifications without wrapper projection", async ( ]); }); +test("routes typed error notifications through the signal router", async () => { + const notification = { + method: "error", + params: { + error: { message: "provider stream reconnecting" }, + threadId: "thread_external", + turnId: "turn_external", + willRetry: true, + }, + }; + const router = new AppServerAgentEventRouter(); + const signals = []; + const unsubscribeSignal = router.subscribeSignal((event) => { + signals.push(event); + }); + + assert.equal(agentRuntimeLifecycleNotification(notification), undefined); + assert.equal(await router.dispatch(notification), true); + unsubscribeSignal(); + assert.deepEqual(signals, [notification]); +}); + +test("routes turn plan updates only through the signal router", async () => { + const notification = { + method: "turn/plan/updated", + params: { + explanation: "继续执行", + plan: [ + { step: "读现状", status: "completed" }, + { step: "补主链", status: "inProgress" }, + ], + threadId: "thread_external", + turnId: "turn_external", + }, + }; + const router = new AppServerAgentEventRouter(); + const lifecycle = []; + const signals = []; + router.subscribeLifecycle((event) => lifecycle.push(event)); + router.subscribeSignal((event) => signals.push(event)); + + assert.equal(agentRuntimeLifecycleNotification(notification), undefined); + assert.equal(await router.dispatch(notification), true); + assert.deepEqual(lifecycle, []); + assert.deepEqual(signals, [notification]); +}); + test("connection buffers request responses read by idle notification loop", async () => { const sent = []; const waiters = []; diff --git a/packages/app-server-client/tests/direct-notifications.test.mjs b/packages/app-server-client/tests/direct-notifications.test.mjs index 562e55628..4c113b4e4 100644 --- a/packages/app-server-client/tests/direct-notifications.test.mjs +++ b/packages/app-server-client/tests/direct-notifications.test.mjs @@ -3,6 +3,7 @@ import { test } from "vitest"; import { isAgentMessageDeltaNotification, isCommandExecutionOutputDeltaNotification, + isErrorNotification, isFileChangePatchUpdatedNotification, isItemCompletedNotification, isItemStartedNotification, @@ -13,19 +14,121 @@ import { isReasoningSummaryTextDeltaNotification, isReasoningTextDeltaNotification, isServerNotification, + isSkillsChangedNotification, isThreadStartedNotification, isThreadSettingsUpdatedNotification, + isTurnPlanUpdatedNotification, isTurnCompletedNotification, isTurnStartedNotification, modelListUpdatedServerNotification, + mcpServerOauthLoginCompletedServerNotification, serverNotification, + skillsChangedServerNotification, } from "../dist/index.js"; const threadId = "thread-1"; const turnId = "turn-1"; +test("recognizes strict skills/changed catalog invalidation", () => { + const notification = { method: "skills/changed", params: {} }; + assert.deepEqual(skillsChangedServerNotification(notification), notification); + assert.equal(isSkillsChangedNotification(notification), true); + + for (const malformed of [ + { method: "skills/changed" }, + { method: "skills/changed", params: null }, + { method: "skills/changed", params: { path: "/private/skill" } }, + ]) { + assert.equal(skillsChangedServerNotification(malformed), undefined); + assert.equal(isSkillsChangedNotification(malformed), false); + } +}); + +test("recognizes strict mcpServer/oauthLogin/completed notifications", () => { + const success = { + method: "mcpServer/oauthLogin/completed", + params: { + name: "remote-docs", + threadId: null, + success: true, + }, + }; + const failure = { + method: "mcpServer/oauthLogin/completed", + params: { + name: "remote-docs", + threadId: "thread-1", + success: false, + error: "scope rejected", + }, + }; + + assert.deepEqual( + mcpServerOauthLoginCompletedServerNotification(success), + success, + ); + assert.deepEqual( + mcpServerOauthLoginCompletedServerNotification(failure), + failure, + ); + + for (const malformed of [ + { method: "mcpServer/oauthLogin/completed" }, + { + method: "mcpServer/oauthLogin/completed", + params: { name: "remote-docs", success: true }, + }, + { + method: "mcpServer/oauthLogin/completed", + params: { name: "", threadId: null, success: true }, + }, + { + method: "mcpServer/oauthLogin/completed", + params: { name: "remote-docs", threadId: 7, success: true }, + }, + { + method: "mcpServer/oauthLogin/completed", + params: { + name: "remote-docs", + threadId: null, + success: false, + error: null, + }, + }, + { + method: "mcpServer/oauthLogin/completed", + params: { + name: "remote-docs", + threadId: null, + success: true, + serverName: "legacy", + }, + }, + ]) { + assert.equal( + mcpServerOauthLoginCompletedServerNotification(malformed), + undefined, + ); + } +}); + test("recognizes native v2 lifecycle and reasoning notifications", () => { const notifications = [ + { + method: "error", + params: { + error: { + message: "provider stream reconnecting", + codexErrorInfo: { + responseStreamDisconnected: { httpStatusCode: null }, + }, + additionalDetails: null, + }, + threadId, + turnId, + willRetry: true, + }, + }, { method: "thread/started", params: { thread: { id: threadId } }, @@ -44,6 +147,18 @@ test("recognizes native v2 lifecycle and reasoning notifications", () => { turn: { id: turnId, status: "completed" }, }, }, + { + method: "turn/plan/updated", + params: { + explanation: "继续执行", + plan: [ + { step: "读取现状", status: "completed" }, + { step: "补齐主链", status: "inProgress" }, + ], + threadId, + turnId, + }, + }, { method: "item/started", params: { @@ -130,28 +245,30 @@ test("recognizes native v2 lifecycle and reasoning notifications", () => { ]; assert.equal(notifications.every(isServerNotification), true); - assert.equal(isThreadStartedNotification(notifications[0]), true); - assert.equal(isTurnStartedNotification(notifications[1]), true); - assert.equal(isTurnCompletedNotification(notifications[2]), true); - assert.equal(isItemStartedNotification(notifications[3]), true); - assert.equal(isItemCompletedNotification(notifications[4]), true); - assert.equal(isAgentMessageDeltaNotification(notifications[5]), true); + assert.equal(isErrorNotification(notifications[0]), true); + assert.equal(isThreadStartedNotification(notifications[1]), true); + assert.equal(isTurnStartedNotification(notifications[2]), true); + assert.equal(isTurnCompletedNotification(notifications[3]), true); + assert.equal(isTurnPlanUpdatedNotification(notifications[4]), true); + assert.equal(isItemStartedNotification(notifications[5]), true); + assert.equal(isItemCompletedNotification(notifications[6]), true); + assert.equal(isAgentMessageDeltaNotification(notifications[7]), true); assert.equal( - isCommandExecutionOutputDeltaNotification(notifications[6]), + isCommandExecutionOutputDeltaNotification(notifications[8]), true, ); - assert.equal(isFileChangePatchUpdatedNotification(notifications[7]), true); - assert.equal(isPlanDeltaNotification(notifications[8]), true); - assert.equal(isMcpToolCallProgressNotification(notifications[9]), true); + assert.equal(isFileChangePatchUpdatedNotification(notifications[9]), true); + assert.equal(isPlanDeltaNotification(notifications[10]), true); + assert.equal(isMcpToolCallProgressNotification(notifications[11]), true); assert.equal( - isReasoningSummaryTextDeltaNotification(notifications[10]), + isReasoningSummaryTextDeltaNotification(notifications[12]), true, ); assert.equal( - isReasoningSummaryPartAddedNotification(notifications[11]), + isReasoningSummaryPartAddedNotification(notifications[13]), true, ); - assert.equal(isReasoningTextDeltaNotification(notifications[12]), true); + assert.equal(isReasoningTextDeltaNotification(notifications[14]), true); assert.equal( isThreadSettingsUpdatedNotification({ method: "thread/settings/updated", @@ -168,6 +285,87 @@ test("recognizes native v2 lifecycle and reasoning notifications", () => { ); }); +test("fails closed for malformed turn plan updates", () => { + const malformed = [ + { + method: "turn/plan/updated", + params: { + plan: [{ step: "补齐主链", status: "running" }], + threadId, + turnId, + }, + }, + { + method: "turn/plan/updated", + params: { + plan: [{ step: "补齐主链", status: "inProgress", extra: true }], + threadId, + turnId, + }, + }, + { + method: "turn/plan/updated", + params: { + plan: [{ step: "", status: "completed" }], + threadId, + turnId, + }, + }, + ]; + + assert.equal( + malformed.every((message) => !isTurnPlanUpdatedNotification(message)), + true, + ); +}); + +test("fails closed for malformed typed error notifications", () => { + const malformed = [ + { error: { message: "failed" }, threadId, turnId }, + { error: { message: "failed" }, threadId, turnId, willRetry: "false" }, + { error: { message: "" }, threadId, turnId, willRetry: false }, + { error: { message: "failed" }, threadId: "", turnId, willRetry: false }, + { error: { message: "failed" }, threadId, turnId: "", willRetry: false }, + { + error: { message: "failed", additionalDetails: 42 }, + threadId, + turnId, + willRetry: false, + }, + { + error: { + message: "failed", + codexErrorInfo: "responseStreamDisconnected", + }, + threadId, + turnId, + willRetry: false, + }, + { + error: { message: "failed", legacyCode: "retryable" }, + threadId, + turnId, + willRetry: false, + }, + { + error: { message: "failed" }, + retryable: true, + threadId, + turnId, + willRetry: false, + }, + ].map((params) => ({ method: "error", params })); + + assert.equal( + malformed.every((message) => !isServerNotification(message)), + true, + ); + assert.equal( + malformed.every((message) => !isErrorNotification(message)), + true, + ); +}); + test("recognizes typed model list update notifications", () => { const providerUpdate = { method: "model/list/updated", diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index b996e9e2f..f18e50662 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.117.0", + "version": "1.118.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/scripts/agent-runtime/claw-chat-current-fixture-assertion-context.mjs b/scripts/agent-runtime/claw-chat-current-fixture-assertion-context.mjs index f06af5838..094591cc7 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-assertion-context.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-assertion-context.mjs @@ -41,6 +41,10 @@ import { TERMINAL_STALE_GUARD_FIRST_PROMPT, TERMINAL_STALE_GUARD_SCENARIO, TERMINAL_STALE_GUARD_SECOND_PROMPT, + TYPED_ERROR_RETRY_FAILURE_PROMPT, + TYPED_ERROR_RETRY_FAILURE_SCENARIO, + TYPED_ERROR_RETRY_SUCCESS_PROMPT, + TYPED_ERROR_RETRY_SUCCESS_SCENARIO, WEB_TOOLS_RENDERING_PROMPT, } from "./claw-chat-current-fixture-constants.mjs"; import { EXPERT_PANEL_SKILLS_RUNTIME_UI_SKILL_REF } from "./claw-chat-current-fixture-expert-actions.mjs"; @@ -113,6 +117,12 @@ export function resolveGateBExpectedIdentity({ } function resolveScenarioTurnStart(summary, options) { + if ( + options?.scenario === TYPED_ERROR_RETRY_SUCCESS_SCENARIO || + options?.scenario === TYPED_ERROR_RETRY_FAILURE_SCENARIO + ) { + return summary?.typedErrorTurnStart ?? null; + } if (options?.scenario === "skills-runtime") { return summary?.manualEnableSkillsRuntimeTurnStart?.backend ?? null; } @@ -370,6 +380,12 @@ export function buildAssertionContext({ entry.kind === "turnStart" && entry.inputText === TERMINAL_STALE_GUARD_SECOND_PROMPT, ); + const typedErrorTurnStart = backendLedger.find( + (entry) => + entry.kind === "turnStart" && + (entry.inputText === TYPED_ERROR_RETRY_SUCCESS_PROMPT || + entry.inputText === TYPED_ERROR_RETRY_FAILURE_PROMPT), + ); const mcpStructuredContentTurnStart = backendLedger.find( (entry) => entry.kind === "turnStart" && @@ -451,6 +467,12 @@ export function buildAssertionContext({ options.scenario === TERMINAL_FAILED_AFTER_ANSWER_SCENARIO; const isTerminalStaleGuardScenario = options.scenario === TERMINAL_STALE_GUARD_SCENARIO; + const isTypedErrorRetrySuccessScenario = + options.scenario === TYPED_ERROR_RETRY_SUCCESS_SCENARIO; + const isTypedErrorRetryFailureScenario = + options.scenario === TYPED_ERROR_RETRY_FAILURE_SCENARIO; + const isTypedErrorRetryScenario = + isTypedErrorRetrySuccessScenario || isTypedErrorRetryFailureScenario; const isMcpStructuredContentScenario = options.scenario === "mcp-structured-content"; const isMediaReferenceScenario = @@ -502,19 +524,21 @@ export function buildAssertionContext({ ? terminalCanceledAfterAnswerTurnStart?.runtimeRequest : isTerminalFailedAfterAnswerScenario ? terminalFailedAfterAnswerTurnStart?.runtimeRequest - : isMcpStructuredContentScenario - ? mcpStructuredContentTurnStart?.runtimeRequest - : isMediaReferenceScenario - ? mediaReferenceTurnStart?.runtimeRequest - : isSkillsRuntimeScenario - ? skillsRuntimeTurnStart?.runtimeRequest - : isAnyExpertSkillsRuntimeScenario - ? expertRuntimeTurnStartForAssertions?.runtimeRequest - : isContentFactoryArticleWorkspaceScenario - ? {} - : isHomeHotpathScenario - ? homeHotpathTurnStart?.runtimeRequest - : newsTurnStart?.runtimeRequest) ?? + : isTypedErrorRetryScenario + ? typedErrorTurnStart?.runtimeRequest + : isMcpStructuredContentScenario + ? mcpStructuredContentTurnStart?.runtimeRequest + : isMediaReferenceScenario + ? mediaReferenceTurnStart?.runtimeRequest + : isSkillsRuntimeScenario + ? skillsRuntimeTurnStart?.runtimeRequest + : isAnyExpertSkillsRuntimeScenario + ? expertRuntimeTurnStartForAssertions?.runtimeRequest + : isContentFactoryArticleWorkspaceScenario + ? {} + : isHomeHotpathScenario + ? homeHotpathTurnStart?.runtimeRequest + : newsTurnStart?.runtimeRequest) ?? {}; const hasCancelPhase = isCancelOnlyScenario || isCancelThenContinueScenario; const goalHarness = readHarnessMetadataFromTurnStart(goalTurnStart); @@ -588,38 +612,43 @@ export function buildAssertionContext({ : isTerminalFailedAfterAnswerScenario ? terminalFailedAfterAnswerTurnStart?.inputText === TERMINAL_FAILED_AFTER_ANSWER_PROMPT - : isTerminalStaleGuardScenario - ? terminalStaleGuardFirstTurnStart?.inputText === - TERMINAL_STALE_GUARD_FIRST_PROMPT && - terminalStaleGuardSecondTurnStart?.inputText === - TERMINAL_STALE_GUARD_SECOND_PROMPT - : isMcpStructuredContentScenario - ? mcpStructuredContentTurnStart?.inputText === - MCP_STRUCTURED_CONTENT_PROMPT - : isMediaReferenceScenario - ? mediaReferenceTurnStart?.inputText === - MEDIA_REFERENCE_PROMPT - : isSkillsRuntimeScenario - ? skillsRuntimeTurnStart?.inputText === - SKILLS_RUNTIME_PROMPT && - explicitSkillsRuntimeTurnStart?.inputText === - SKILLS_RUNTIME_EXPLICIT_PROMPT && - manualEnableSkillsRuntimeTurnStart?.inputText === - SKILLS_RUNTIME_MANUAL_ENABLE_PROMPT - : isAnyExpertSkillsRuntimeScenario - ? isExpertPanelSkillsRuntimeScenario - ? expertPanelSkillsRuntimeTurnStart?.inputText === - EXPERT_SKILLS_RUNTIME_PANEL_PROMPT - : expertSkillsRuntimeTurnStart?.inputText?.includes( - EXPERT_SKILLS_RUNTIME_PROMPT, - ) === true - : isContentFactoryArticleWorkspaceScenario - ? true - : isSoulStyleScenario - ? newsTraceTurnStart?.inputText === - NEWS_PROMPT - : newsTurnStart?.inputText === - NEWS_PROMPT; + : isTypedErrorRetryScenario + ? typedErrorTurnStart?.inputText === + (isTypedErrorRetrySuccessScenario + ? TYPED_ERROR_RETRY_SUCCESS_PROMPT + : TYPED_ERROR_RETRY_FAILURE_PROMPT) + : isTerminalStaleGuardScenario + ? terminalStaleGuardFirstTurnStart?.inputText === + TERMINAL_STALE_GUARD_FIRST_PROMPT && + terminalStaleGuardSecondTurnStart?.inputText === + TERMINAL_STALE_GUARD_SECOND_PROMPT + : isMcpStructuredContentScenario + ? mcpStructuredContentTurnStart?.inputText === + MCP_STRUCTURED_CONTENT_PROMPT + : isMediaReferenceScenario + ? mediaReferenceTurnStart?.inputText === + MEDIA_REFERENCE_PROMPT + : isSkillsRuntimeScenario + ? skillsRuntimeTurnStart?.inputText === + SKILLS_RUNTIME_PROMPT && + explicitSkillsRuntimeTurnStart?.inputText === + SKILLS_RUNTIME_EXPLICIT_PROMPT && + manualEnableSkillsRuntimeTurnStart?.inputText === + SKILLS_RUNTIME_MANUAL_ENABLE_PROMPT + : isAnyExpertSkillsRuntimeScenario + ? isExpertPanelSkillsRuntimeScenario + ? expertPanelSkillsRuntimeTurnStart?.inputText === + EXPERT_SKILLS_RUNTIME_PANEL_PROMPT + : expertSkillsRuntimeTurnStart?.inputText?.includes( + EXPERT_SKILLS_RUNTIME_PROMPT, + ) === true + : isContentFactoryArticleWorkspaceScenario + ? true + : isSoulStyleScenario + ? newsTraceTurnStart?.inputText === + NEWS_PROMPT + : newsTurnStart?.inputText === + NEWS_PROMPT; return { backendLedger, traceMessages, @@ -656,6 +685,7 @@ export function buildAssertionContext({ terminalFailedAfterAnswerTurnStart, terminalStaleGuardFirstTurnStart, terminalStaleGuardSecondTurnStart, + typedErrorTurnStart, mcpStructuredContentTurnStart, mediaReferenceTurnStart, skillsRuntimeTurnStart, @@ -686,6 +716,9 @@ export function buildAssertionContext({ isTerminalCanceledAfterAnswerScenario, isTerminalFailedAfterAnswerScenario, isTerminalStaleGuardScenario, + isTypedErrorRetrySuccessScenario, + isTypedErrorRetryFailureScenario, + isTypedErrorRetryScenario, isMcpStructuredContentScenario, isMediaReferenceScenario, isSkillsRuntimeScenario, diff --git a/scripts/agent-runtime/claw-chat-current-fixture-assertions.mjs b/scripts/agent-runtime/claw-chat-current-fixture-assertions.mjs index ed7a01031..66a0a6a95 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-assertions.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-assertions.mjs @@ -5,13 +5,25 @@ import { buildNotApplicableAssertions } from "./claw-chat-current-fixture-not-ap import { buildScenarioAssertions } from "./claw-chat-current-fixture-scenario-assertions.mjs"; import { assert } from "./claw-chat-current-fixture-utils.mjs"; import { buildGateBContractAssertions } from "./claw-chat-current-fixture-gate-b-contract.mjs"; +import { + buildTurnPlanUpdateScenarioAssertions, + TURN_PLAN_UPDATE_SCENARIO, +} from "./claw-chat-current-fixture-turn-plan-update.mjs"; export function buildFixtureAssertionReport(input) { const backendSummary = summarizeBackendLedger(input.backendLedger); const context = buildAssertionContext(input); - const commonAssertions = buildCommonAssertions(context); - const scenarioAssertions = buildScenarioAssertions(context); - const notApplicableAssertions = buildNotApplicableAssertions(context); + const isTurnPlanUpdateScenario = + input.options?.scenario === TURN_PLAN_UPDATE_SCENARIO; + const commonAssertions = isTurnPlanUpdateScenario + ? {} + : buildCommonAssertions(context); + const scenarioAssertions = isTurnPlanUpdateScenario + ? buildTurnPlanUpdateScenarioAssertions(context) + : buildScenarioAssertions(context); + const notApplicableAssertions = isTurnPlanUpdateScenario + ? [] + : buildNotApplicableAssertions(context); const gateBContractAssertions = buildGateBContractAssertions( context.gateBContract, ); diff --git a/scripts/agent-runtime/claw-chat-current-fixture-backend-file.mjs b/scripts/agent-runtime/claw-chat-current-fixture-backend-file.mjs index bb63329d2..3ddf039fd 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-backend-file.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-backend-file.mjs @@ -13,6 +13,11 @@ import { NEWS_PROMPT, TEXT_PROVIDER_FIXTURE_API_KEY, TEXT_FIXTURE_PROVIDER_NAME, + TURN_PLAN_UPDATE_DONE_TEXT, + TURN_PLAN_UPDATE_EXPLANATION, + TURN_PLAN_UPDATE_PROMPT, + TURN_PLAN_UPDATE_STEPS, + TURN_PLAN_UPDATE_TOOL_CALL_ID, } from "./claw-chat-current-fixture-constants.mjs"; import { writeFixtureBackend } from "./claw-chat-current-fixture-backend-script.mjs"; import { @@ -87,6 +92,10 @@ export function createTempRuntimeEnv() { const backendPath = path.join(tempRoot, "claw-chat-backend.mjs"); const backendLedgerPath = path.join(tempRoot, "claw-chat-backend.jsonl"); const cancelSignalPath = path.join(tempRoot, "claw-chat-cancel.signal"); + const typedErrorSignalPath = path.join( + tempRoot, + "claw-chat-typed-error.signal", + ); const mediaReferenceSourcePath = path.join( tempRoot, "fixture-media-reference.png", @@ -116,6 +125,7 @@ export function createTempRuntimeEnv() { writeFixtureConfig(homeConfigPath); writeFixtureConfig(macConfigPath); fs.writeFileSync(backendLedgerPath, ""); + fs.writeFileSync(typedErrorSignalPath, ""); fs.writeFileSync( mediaReferenceSourcePath, Buffer.from(IMAGE_PROVIDER_FIXTURE_DATA_URL.split(",")[1] || "", "base64"), @@ -129,6 +139,7 @@ export function createTempRuntimeEnv() { backendPath, backendLedgerPath, cancelSignalPath, + typedErrorSignalPath, mediaReferenceSourcePath, configPath, macConfigPath, @@ -266,6 +277,12 @@ export async function startImageProviderFixtureServer() { function fixtureTextForChatRequest(body) { const serialized = typeof body === "string" ? body : JSON.stringify(body); + if ( + serialized.includes(TURN_PLAN_UPDATE_PROMPT) && + serialized.includes("Plan updated") + ) { + return `执行清单已更新,主链继续运行。\n${TURN_PLAN_UPDATE_DONE_TEXT}`; + } if (serialized.includes(ACTIVE_STEER_INPUT)) { return `${ACTIVE_STEER_FINAL_TEXT}\n${ACTIVE_STEER_DONE_TEXT}`; } @@ -347,6 +364,7 @@ function summarizeChatCompletionRequestBody(body, expectedSoulStyle) { parsed = {}; } const messages = Array.isArray(parsed.messages) ? parsed.messages : []; + const tools = Array.isArray(parsed.tools) ? parsed.tools : []; const serialized = body || ""; return { stream: parsed.stream ?? null, @@ -355,6 +373,22 @@ function summarizeChatCompletionRequestBody(body, expectedSoulStyle) { responseFormatType: parsed.response_format?.type ?? parsed.responseFormat?.type ?? null, toolChoice: parsed.tool_choice ?? parsed.toolChoice ?? null, + toolNames: tools + .map((tool) => tool?.function?.name ?? tool?.name ?? null) + .filter(Boolean), + bodyIncludesUpdatePlanToolDefinition: tools.some( + (tool) => (tool?.function?.name ?? tool?.name) === "update_plan", + ), + bodyIncludesTurnPlanUpdatePrompt: messages.some( + (message) => + message?.role === "user" && + readChatContentText(message?.content).includes(TURN_PLAN_UPDATE_PROMPT), + ), + bodyIncludesPlanUpdatedToolResult: messages.some( + (message) => + message?.role === "tool" && + readChatContentText(message?.content).includes("Plan updated"), + ), soulMarkers: summarizeSoulPromptMarkers(serialized, expectedSoulStyle), bodyIncludesPresentationContract: serialized.includes("image_task_presentation.v1") || @@ -444,7 +478,45 @@ function openaiChatCompletionSseBody(content) { return `data: ${firstChunk}\n\ndata: ${finalChunk}\n\ndata: ${usageChunk}\n\ndata: [DONE]\n\n`; } -function openaiModelsBody() { +function openaiToolCallSseBody({ name, callId, argumentsValue }) { + const toolChunk = JSON.stringify({ + id: "chatcmpl-claw-text-fixture", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: FIXTURE_MODEL, + choices: [ + { + index: 0, + delta: { + role: "assistant", + tool_calls: [ + { + index: 0, + id: callId, + type: "function", + function: { + name, + arguments: JSON.stringify(argumentsValue), + }, + }, + ], + }, + finish_reason: null, + }, + ], + }); + const finalChunk = JSON.stringify({ + id: "chatcmpl-claw-text-fixture", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: FIXTURE_MODEL, + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + }); + const usageChunk = JSON.stringify(openaiChatCompletionUsageChunk()); + return `data: ${toolChunk}\n\ndata: ${finalChunk}\n\ndata: ${usageChunk}\n\ndata: [DONE]\n\n`; +} + +function openaiModelsBody({ supportsTools = false } = {}) { return { object: "list", data: [ @@ -456,10 +528,13 @@ function openaiModelsBody() { input_modalities: ["text", "image"], output_modalities: ["text"], task_families: ["chat", "vision_understanding"], - runtime_features: ["streaming"], + runtime_features: supportsTools + ? ["streaming", "tool_calling"] + : ["streaming"], capabilities: { vision: true, streaming: true, + tools: supportsTools, }, }, ], @@ -469,6 +544,7 @@ function openaiModelsBody() { export async function startTextProviderFixtureServer({ soulStyleExpectation = null, activeSteerDelayMs = 0, + turnPlanUpdate = false, } = {}) { const requests = []; const expectedAuthorization = `Bearer ${TEXT_PROVIDER_FIXTURE_API_KEY}`; @@ -512,7 +588,9 @@ export async function startTextProviderFixtureServer({ ["/models", "/v1/models"].includes(pathname) ) { response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify(openaiModelsBody())); + response.end( + JSON.stringify(openaiModelsBody({ supportsTools: turnPlanUpdate })), + ); return; } if ( @@ -530,6 +608,44 @@ export async function startTextProviderFixtureServer({ } catch { parsedBody = {}; } + const messages = Array.isArray(parsedBody.messages) + ? parsedBody.messages + : []; + const hasTurnPlanPrompt = messages.some( + (message) => + message?.role === "user" && + readChatContentText(message?.content).includes( + TURN_PLAN_UPDATE_PROMPT, + ), + ); + const hasPlanUpdatedToolResult = messages.some( + (message) => + message?.role === "tool" && + readChatContentText(message?.content).includes("Plan updated"), + ); + if ( + turnPlanUpdate && + parsedBody.stream !== false && + hasTurnPlanPrompt && + !hasPlanUpdatedToolResult + ) { + const responseBody = openaiToolCallSseBody({ + argumentsValue: { + explanation: TURN_PLAN_UPDATE_EXPLANATION, + plan: TURN_PLAN_UPDATE_STEPS, + }, + callId: TURN_PLAN_UPDATE_TOOL_CALL_ID, + name: "update_plan", + }); + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "close", + "content-length": Buffer.byteLength(responseBody), + }); + response.end(responseBody); + return; + } const content = fixtureTextForChatRequest(body); if (parsedBody.stream === false) { response.writeHead(200, { "content-type": "application/json" }); diff --git a/scripts/agent-runtime/claw-chat-current-fixture-backend-ledger.mjs b/scripts/agent-runtime/claw-chat-current-fixture-backend-ledger.mjs index acafac80a..820a995e5 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-backend-ledger.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-backend-ledger.mjs @@ -58,6 +58,27 @@ export function sanitizeBackendLedgerForEvidence(backendLedger) { recordedAt: entry.recordedAt, }); } + if ( + entry?.kind === "typedErrorRetryingAwaitingSignal" || + entry?.kind === "typedErrorAwaitingTerminalSignal" || + entry?.kind === "typedErrorTerminalEmitted" + ) { + return sanitizeJson({ + kind: entry.kind, + scenario: entry.scenario, + sessionId: entry.sessionId, + threadId: entry.threadId, + turnId: entry.turnId, + eventType: entry.eventType, + eventTypes: entry.eventTypes, + retryCount: entry.retryCount, + expectedWillRetry: entry.expectedWillRetry, + retryable: entry.retryable, + signalStage: entry.signalStage, + terminalStatus: entry.terminalStatus, + recordedAt: entry.recordedAt, + }); + } if ( entry?.kind === "actionRespond" || entry?.kind === "approvalRequestResumeActionRespond" || diff --git a/scripts/agent-runtime/claw-chat-current-fixture-backend-script.mjs b/scripts/agent-runtime/claw-chat-current-fixture-backend-script.mjs index 47b4886c5..fca92ec5d 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-backend-script.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-backend-script.mjs @@ -67,6 +67,17 @@ import { TERMINAL_STALE_GUARD_SECOND_TEXT, TERMINAL_STALE_GUARD_STALE_DONE_TEXT, THREAD_ID, + TYPED_ERROR_RETRY_FAILURE_ERROR_TEXT, + TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT, + TYPED_ERROR_RETRY_FAILURE_PROMPT, + TYPED_ERROR_RETRY_FAILURE_SCENARIO, + TYPED_ERROR_RETRY_FAILURE_TEXT, + TYPED_ERROR_RETRY_MESSAGE, + TYPED_ERROR_RETRY_SECOND_MESSAGE, + TYPED_ERROR_RETRY_SUCCESS_DONE_TEXT, + TYPED_ERROR_RETRY_SUCCESS_PROMPT, + TYPED_ERROR_RETRY_SUCCESS_SCENARIO, + TYPED_ERROR_RETRY_SUCCESS_TEXT, WEB_TOOLS_BROKEN_MARKDOWN_TEXT, WEB_TOOLS_RENDERING_DONE_TEXT, WEB_TOOLS_RENDERING_PROMPT, @@ -270,6 +281,7 @@ import { appendFileSync, readFileSync } from "node:fs"; const ledgerPath = process.argv[2]; const cancelSignalPath = process.argv[3]; +const typedErrorSignalPath = process.argv[4]; const mediaReferenceSourcePath = ${JSON.stringify(mediaReferenceSourcePath)}; const input = JSON.parse(readFileSync(0, "utf8")); const runtimeRequest = input.request?.runtimeOptions?.runtimeRequest; @@ -315,6 +327,31 @@ export function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +export function readTypedErrorSignals() { + if (!typedErrorSignalPath) { + return []; + } + try { + return readFileSync(typedErrorSignalPath, "utf8") + .split("\\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + } catch { + return []; + } +} + +export async function waitForTypedErrorSignal(stage) { + const startedAt = Date.now(); + while (Date.now() - startedAt < 120000) { + if (readTypedErrorSignals().some((entry) => entry?.stage === stage)) { + return; + } + await sleep(50); + } + throw new Error("typed error scenario timed out waiting for signal: " + stage); +} + export function currentThreadId() { return input.request?.session?.threadId ?? input.request?.session?.thread_id ?? @@ -432,6 +469,8 @@ if (input.kind === "turnStart") { const isTerminalFailedAfterAnswerPrompt = inputText.includes("${TERMINAL_FAILED_AFTER_ANSWER_PROMPT}"); const isTerminalStaleGuardFirstPrompt = inputText.includes("${TERMINAL_STALE_GUARD_FIRST_PROMPT}"); const isTerminalStaleGuardSecondPrompt = inputText.includes("${TERMINAL_STALE_GUARD_SECOND_PROMPT}"); + const isTypedErrorRetrySuccessPrompt = inputText.includes("${TYPED_ERROR_RETRY_SUCCESS_PROMPT}"); + const isTypedErrorRetryFailurePrompt = inputText.includes("${TYPED_ERROR_RETRY_FAILURE_PROMPT}"); const isWebToolsRenderingPrompt = inputText.includes("${WEB_TOOLS_RENDERING_PROMPT}"); const isMcpStructuredContentPrompt = inputText.includes("${MCP_STRUCTURED_CONTENT_PROMPT}"); const isMediaReferencePrompt = inputText.includes("${MEDIA_REFERENCE_PROMPT}"); @@ -581,6 +620,153 @@ if (input.kind === "turnStart") { } ]; } + if (isTypedErrorRetrySuccessPrompt || isTypedErrorRetryFailurePrompt) { + const scenario = isTypedErrorRetrySuccessPrompt + ? "${TYPED_ERROR_RETRY_SUCCESS_SCENARIO}" + : "${TYPED_ERROR_RETRY_FAILURE_SCENARIO}"; + const retryEvents = [ + { + type: "plugin_worker.retry", + payload: { + message: "${TYPED_ERROR_RETRY_MESSAGE}", + errorCode: "server_overloaded", + retryable: true, + retryAttempt: 1, + retryMaxAttempts: isTypedErrorRetrySuccessPrompt ? 1 : 2 + } + }, + ...(isTypedErrorRetryFailurePrompt + ? [{ + type: "plugin_worker.retry", + payload: { + message: "${TYPED_ERROR_RETRY_SECOND_MESSAGE}", + errorCode: "server_overloaded", + retryable: true, + retryAttempt: 2, + retryMaxAttempts: 2 + } + }] + : []) + ]; + emitEvents(retryEvents); + appendLedgerEntry({ + kind: "typedErrorRetryingAwaitingSignal", + scenario, + sessionId: input.request?.session?.sessionId, + threadId: currentThreadId(), + turnId: currentTurnId(), + eventTypes: retryEvents.map((event) => event.type), + retryCount: retryEvents.length, + expectedWillRetry: true, + signalStage: "retry-visible" + }); + await waitForTypedErrorSignal("retry-visible"); + + if (isTypedErrorRetrySuccessPrompt) { + emitEvents([ + { + type: "provider.first_text_delta.received", + payload: providerTracePayload("first_text_delta_received", 180, "running", { + attempt: 2, + text_chars: "${TYPED_ERROR_RETRY_SUCCESS_TEXT}".length, + textChars: "${TYPED_ERROR_RETRY_SUCCESS_TEXT}".length + }) + }, + { + type: "message.delta", + payload: messageDeltaPayload( + "${TYPED_ERROR_RETRY_SUCCESS_TEXT}\\n${TYPED_ERROR_RETRY_SUCCESS_DONE_TEXT}", + "final_answer", + finalAnswerItemId + ) + }, + { + type: "turn.completed", + payload: { + status: "completed", + text: "${TYPED_ERROR_RETRY_SUCCESS_DONE_TEXT}" + } + } + ]); + appendLedgerEntry({ + kind: "typedErrorTerminalEmitted", + scenario, + sessionId: input.request?.session?.sessionId, + threadId: currentThreadId(), + turnId: currentTurnId(), + eventType: "turn.completed", + terminalStatus: "completed" + }); + process.exit(0); + } + + emitEvents([ + { + type: "runtime.error", + payload: { + message: "${TYPED_ERROR_RETRY_FAILURE_ERROR_TEXT}", + errorCode: "server_overloaded", + retryable: true, + willRetry: false, + retryAttempt: 2, + retryMaxAttempts: 2, + failureCategory: "fixture_retry_exhausted" + } + } + ]); + appendLedgerEntry({ + kind: "typedErrorAwaitingTerminalSignal", + scenario, + sessionId: input.request?.session?.sessionId, + threadId: currentThreadId(), + turnId: currentTurnId(), + eventType: "runtime.error", + expectedWillRetry: false, + retryable: true, + signalStage: "terminal-visible" + }); + await waitForTypedErrorSignal("terminal-visible"); + emitEvents([ + { + type: "provider.first_text_delta.received", + payload: providerTracePayload("first_text_delta_received", 270, "running", { + attempt: 3, + text_chars: "${TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT}".length, + textChars: "${TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT}".length + }) + }, + { + type: "message.delta", + payload: messageDeltaPayload( + "${TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT}", + "final_answer", + finalAnswerItemId + ) + }, + { + type: "turn.failed", + payload: { + status: "failed", + message: "${TYPED_ERROR_RETRY_FAILURE_ERROR_TEXT}", + errorCode: "server_overloaded", + retryable: true, + willRetry: false, + failureCategory: "fixture_retry_exhausted" + } + } + ]); + appendLedgerEntry({ + kind: "typedErrorTerminalEmitted", + scenario, + sessionId: input.request?.session?.sessionId, + threadId: currentThreadId(), + turnId: currentTurnId(), + eventType: "turn.failed", + terminalStatus: "failed", + failureText: "${TYPED_ERROR_RETRY_FAILURE_TEXT}" + }); + process.exit(0); + } if (isImageTaskPresentationPrompt) { const presentationText = JSON.stringify({ assistant_intro: ${JSON.stringify(IMAGE_COMMAND_PRESENTATION_INTRO)}, diff --git a/scripts/agent-runtime/claw-chat-current-fixture-common-assertions.mjs b/scripts/agent-runtime/claw-chat-current-fixture-common-assertions.mjs index 65b96bb50..fc48b2ee4 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-common-assertions.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-common-assertions.mjs @@ -48,6 +48,10 @@ import { TERMINAL_STALE_GUARD_SECOND_PROMPT, TERMINAL_STALE_GUARD_SECOND_TEXT, TERMINAL_STALE_GUARD_STALE_DONE_TEXT, + TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT, + TYPED_ERROR_RETRY_FAILURE_PROMPT, + TYPED_ERROR_RETRY_SUCCESS_PROMPT, + TYPED_ERROR_RETRY_SUCCESS_TEXT, } from "./claw-chat-current-fixture-constants.mjs"; import { MEDIA_REFERENCE_PROMPT, @@ -92,6 +96,8 @@ export function buildCommonAssertions(context) { isTerminalCanceledAfterAnswerScenario, isTerminalFailedAfterAnswerScenario, isTerminalStaleGuardScenario, + isTypedErrorRetryScenario, + isTypedErrorRetrySuccessScenario, isSkillsRuntimeScenario, isSoulStyleScenario, isRightSurfaceVisualMatrixScenario, @@ -126,6 +132,8 @@ export function buildCommonAssertions(context) { !isApprovalRequestResumeScenario && !isApprovalRequestDecisionScenario && !isApprovalRequestFullAccessScenario; + const shouldRequireProviderTextTraceEvidence = + shouldRequireTextStreamTraceSeparation && !isTypedErrorRetryScenario; const agentUiPerformanceTrace = summary.agentUiPerformanceTrace; const appServerTraceEvidence = summary.appServerTraceEvidence; const appServerTraceEvidenceDeferred = @@ -303,33 +311,37 @@ export function buildCommonAssertions(context) { summary .guiTerminalStaleGuardSecondCompleted ?.hasPrompt === true - : isMcpStructuredContentScenario - ? summary - .guiMcpStructuredContentCompleted + : isTypedErrorRetryScenario + ? summary.guiTypedErrorTerminal ?.hasPrompt === true - : isMediaReferenceScenario - ? summary.guiMediaReferenceCompleted + : isMcpStructuredContentScenario + ? summary + .guiMcpStructuredContentCompleted ?.hasPrompt === true - : isSkillsRuntimeScenario + : isMediaReferenceScenario ? summary - .guiSkillsRuntimeCompleted - ?.hasPrompt === true && - summary - .guiExplicitSkillsRuntimeCompleted - ?.hasPrompt === true && - summary - .guiManualEnableSkillsRuntimeCompleted + .guiMediaReferenceCompleted ?.hasPrompt === true - : isAnyExpertSkillsRuntimeScenario - ? isExpertPanelSkillsRuntimeScenario - ? summary - .guiExpertPanelSkillsRuntimeCompleted - ?.hasPrompt === true - : summary - .guiExpertSkillsRuntimeCompleted - ?.hasPrompt === true - : summary.guiCompleted - ?.hasPrompt === true, + : isSkillsRuntimeScenario + ? summary + .guiSkillsRuntimeCompleted + ?.hasPrompt === true && + summary + .guiExplicitSkillsRuntimeCompleted + ?.hasPrompt === true && + summary + .guiManualEnableSkillsRuntimeCompleted + ?.hasPrompt === true + : isAnyExpertSkillsRuntimeScenario + ? isExpertPanelSkillsRuntimeScenario + ? summary + .guiExpertPanelSkillsRuntimeCompleted + ?.hasPrompt === true + : summary + .guiExpertSkillsRuntimeCompleted + ?.hasPrompt === true + : summary.guiCompleted + ?.hasPrompt === true, guiAssistantOutputVisible: isCancelOnlyScenario ? summary.guiCanceled?.hasStoppedCopy === true : isCancelThenContinueScenario @@ -422,68 +434,76 @@ export function buildCommonAssertions(context) { summary .guiTerminalStaleGuardSecondCompleted ?.hasDoneText === true - : isMcpStructuredContentScenario - ? (summary - .guiMcpStructuredContentCompleted - ?.hasStructuredAnswer === true && + : isTypedErrorRetryScenario + ? summary.guiTypedErrorTerminal + ?.hasAssistantSummary === true || + summary.guiTypedErrorTerminal + ?.hasDoneText === true + : isMcpStructuredContentScenario + ? (summary + .guiMcpStructuredContentCompleted + ?.hasStructuredAnswer === + true && + summary + .guiMcpStructuredContentCompleted + ?.hasReferenceId === true) || summary .guiMcpStructuredContentCompleted - ?.hasReferenceId === true) || - summary - .guiMcpStructuredContentCompleted - ?.hasDoneText === true || - summary - .guiMcpStructuredContentCompleted - ?.terminalDetailsCompacted === - true - : isMediaReferenceScenario - ? summary.guiMediaReferenceSnapshot - ?.hasCard === true && - summary.guiMediaReferenceSnapshot - ?.hasUri === true - : isSkillsRuntimeScenario + ?.hasDoneText === true || + summary + .guiMcpStructuredContentCompleted + ?.terminalDetailsCompacted === + true + : isMediaReferenceScenario ? summary - .guiSkillsRuntimeCompleted - ?.hasAssistantSummary === - true || + .guiMediaReferenceSnapshot + ?.hasCard === true && summary - .guiSkillsRuntimeCompleted - ?.hasDoneText === true || - summary - .guiExplicitSkillsRuntimeCompleted - ?.hasAssistantSummary === - true || - summary - .guiExplicitSkillsRuntimeCompleted - ?.hasDoneText === true || - summary - .guiManualEnableSkillsRuntimeCompleted - ?.hasAssistantSummary === - true || - summary - .guiManualEnableSkillsRuntimeCompleted - ?.hasDoneText === true - : isAnyExpertSkillsRuntimeScenario - ? isExpertPanelSkillsRuntimeScenario - ? summary - .guiExpertPanelSkillsRuntimeCompleted - ?.hasAssistantSummary === - true || - summary - .guiExpertPanelSkillsRuntimeCompleted - ?.hasDoneText === true - : summary - .guiExpertSkillsRuntimeCompleted - ?.hasAssistantSummary === - true || - summary - .guiExpertSkillsRuntimeCompleted - ?.hasDoneText === true - : summary.guiCompleted + .guiMediaReferenceSnapshot + ?.hasUri === true + : isSkillsRuntimeScenario + ? summary + .guiSkillsRuntimeCompleted ?.hasAssistantSummary === true || - summary.guiCompleted - ?.hasDoneText === true, + summary + .guiSkillsRuntimeCompleted + ?.hasDoneText === true || + summary + .guiExplicitSkillsRuntimeCompleted + ?.hasAssistantSummary === + true || + summary + .guiExplicitSkillsRuntimeCompleted + ?.hasDoneText === true || + summary + .guiManualEnableSkillsRuntimeCompleted + ?.hasAssistantSummary === + true || + summary + .guiManualEnableSkillsRuntimeCompleted + ?.hasDoneText === true + : isAnyExpertSkillsRuntimeScenario + ? isExpertPanelSkillsRuntimeScenario + ? summary + .guiExpertPanelSkillsRuntimeCompleted + ?.hasAssistantSummary === + true || + summary + .guiExpertPanelSkillsRuntimeCompleted + ?.hasDoneText === true + : summary + .guiExpertSkillsRuntimeCompleted + ?.hasAssistantSummary === + true || + summary + .guiExpertSkillsRuntimeCompleted + ?.hasDoneText === true + : summary.guiCompleted + ?.hasAssistantSummary === + true || + summary.guiCompleted + ?.hasDoneText === true, guiInputRemainsReady: isCancelOnlyScenario ? summary.guiCanceled?.textareaVisible === true && summary.guiCanceled?.textareaDisabled === false @@ -569,63 +589,73 @@ export function buildCommonAssertions(context) { summary .guiTerminalStaleGuardSecondCompleted ?.textareaDisabled === false - : isMcpStructuredContentScenario - ? summary - .guiMcpStructuredContentCompleted + : isTypedErrorRetryScenario + ? summary.guiTypedErrorTerminal ?.textareaVisible === true && - summary - .guiMcpStructuredContentCompleted + summary.guiTypedErrorTerminal ?.textareaDisabled === false - : isMediaReferenceScenario - ? summary.guiMediaReferenceCompleted + : isMcpStructuredContentScenario + ? summary + .guiMcpStructuredContentCompleted ?.textareaVisible === true && - summary.guiMediaReferenceCompleted + summary + .guiMcpStructuredContentCompleted ?.textareaDisabled === false - : isSkillsRuntimeScenario + : isMediaReferenceScenario ? summary - .guiSkillsRuntimeCompleted + .guiMediaReferenceCompleted ?.textareaVisible === true && summary - .guiSkillsRuntimeCompleted - ?.textareaDisabled === - false && - summary - .guiExplicitSkillsRuntimeCompleted - ?.textareaVisible === true && - summary - .guiExplicitSkillsRuntimeCompleted - ?.textareaDisabled === - false && - summary - .guiManualEnableSkillsRuntimeCompleted - ?.textareaVisible === true && - summary - .guiManualEnableSkillsRuntimeCompleted + .guiMediaReferenceCompleted ?.textareaDisabled === false - : isAnyExpertSkillsRuntimeScenario - ? isExpertPanelSkillsRuntimeScenario - ? summary - .guiExpertPanelSkillsRuntimeCompleted - ?.textareaVisible === - true && - summary - .guiExpertPanelSkillsRuntimeCompleted - ?.textareaDisabled === - false - : summary - .guiExpertSkillsRuntimeCompleted - ?.textareaVisible === - true && - summary - .guiExpertSkillsRuntimeCompleted - ?.textareaDisabled === - false - : summary.guiCompleted + : isSkillsRuntimeScenario + ? summary + .guiSkillsRuntimeCompleted ?.textareaVisible === true && - summary.guiCompleted + summary + .guiSkillsRuntimeCompleted ?.textareaDisabled === - false, + false && + summary + .guiExplicitSkillsRuntimeCompleted + ?.textareaVisible === + true && + summary + .guiExplicitSkillsRuntimeCompleted + ?.textareaDisabled === + false && + summary + .guiManualEnableSkillsRuntimeCompleted + ?.textareaVisible === + true && + summary + .guiManualEnableSkillsRuntimeCompleted + ?.textareaDisabled === false + : isAnyExpertSkillsRuntimeScenario + ? isExpertPanelSkillsRuntimeScenario + ? summary + .guiExpertPanelSkillsRuntimeCompleted + ?.textareaVisible === + true && + summary + .guiExpertPanelSkillsRuntimeCompleted + ?.textareaDisabled === + false + : summary + .guiExpertSkillsRuntimeCompleted + ?.textareaVisible === + true && + summary + .guiExpertSkillsRuntimeCompleted + ?.textareaDisabled === + false + : summary.guiCompleted + ?.textareaVisible === + true && + summary.guiCompleted + ?.textareaDisabled === + false, guiNotStuckStreaming: isCancelOnlyScenario ? summary.guiCanceled?.stopButtonVisible === false : isCancelThenContinueScenario @@ -677,38 +707,43 @@ export function buildCommonAssertions(context) { ? summary .guiTerminalStaleGuardSecondCompleted ?.stopButtonVisible === false - : isMcpStructuredContentScenario - ? summary - .guiMcpStructuredContentCompleted + : isTypedErrorRetryScenario + ? summary.guiTypedErrorTerminal ?.stopButtonVisible === false - : isMediaReferenceScenario - ? summary.guiMediaReferenceCompleted + : isMcpStructuredContentScenario + ? summary + .guiMcpStructuredContentCompleted ?.stopButtonVisible === false - : isSkillsRuntimeScenario + : isMediaReferenceScenario ? summary - .guiSkillsRuntimeCompleted - ?.stopButtonVisible === - false && - summary - .guiExplicitSkillsRuntimeCompleted - ?.stopButtonVisible === - false && - summary - .guiManualEnableSkillsRuntimeCompleted + .guiMediaReferenceCompleted ?.stopButtonVisible === false - : isAnyExpertSkillsRuntimeScenario - ? isExpertPanelSkillsRuntimeScenario - ? summary - .guiExpertPanelSkillsRuntimeCompleted - ?.stopButtonVisible === - false - : summary - .guiExpertSkillsRuntimeCompleted - ?.stopButtonVisible === - false - : summary.guiCompleted + : isSkillsRuntimeScenario + ? summary + .guiSkillsRuntimeCompleted ?.stopButtonVisible === - false, + false && + summary + .guiExplicitSkillsRuntimeCompleted + ?.stopButtonVisible === + false && + summary + .guiManualEnableSkillsRuntimeCompleted + ?.stopButtonVisible === + false + : isAnyExpertSkillsRuntimeScenario + ? isExpertPanelSkillsRuntimeScenario + ? summary + .guiExpertPanelSkillsRuntimeCompleted + ?.stopButtonVisible === + false + : summary + .guiExpertSkillsRuntimeCompleted + ?.stopButtonVisible === + false + : summary.guiCompleted + ?.stopButtonVisible === + false, guiRunningStatusPreservedBeforeStop: !hasCancelPhase || (summary.stopClick?.beforeClick?.hasVisibleAssistantOutput === true && @@ -907,107 +942,123 @@ export function buildCommonAssertions(context) { !pageText.includes( TERMINAL_STALE_GUARD_STALE_DONE_TEXT, ) - : isMcpStructuredContentScenario - ? summary - .guiMcpStructuredContentCompleted - ?.hasPrompt === true && - ((summary - .guiMcpStructuredContentCompleted - ?.hasStructuredAnswer === - true && - summary + : isTypedErrorRetryScenario + ? pageText.includes( + isTypedErrorRetrySuccessScenario + ? TYPED_ERROR_RETRY_SUCCESS_PROMPT + : TYPED_ERROR_RETRY_FAILURE_PROMPT, + ) && + pageText.includes( + isTypedErrorRetrySuccessScenario + ? TYPED_ERROR_RETRY_SUCCESS_TEXT + : TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT, + ) + : isMcpStructuredContentScenario + ? summary .guiMcpStructuredContentCompleted - ?.hasReferenceId === true) || - summary + ?.hasPrompt === true && + ((summary .guiMcpStructuredContentCompleted - ?.hasDoneText === true || - summary - .guiMcpStructuredContentCompleted - ?.terminalDetailsCompacted === - true) && - summary - .guiMcpStructuredContentCompleted - ?.envelopeVisible === false - : isMediaReferenceScenario - ? pageText.includes( - MEDIA_REFERENCE_PROMPT, - ) && - pageText.includes( - MEDIA_REFERENCE_SUMMARY_TEXT, - ) && - pageText.includes( - MEDIA_REFERENCE_URI, - ) - : isSkillsRuntimeScenario - ? summary - .guiSkillsRuntimeCompleted - ?.hasPrompt === true && - (summary - .guiSkillsRuntimeCompleted - ?.hasAssistantSummary === - true || - summary - .guiSkillsRuntimeCompleted - ?.hasDoneText === true) && + ?.hasStructuredAnswer === + true && summary - .guiExplicitSkillsRuntimeCompleted - ?.hasPrompt === true && - (summary - .guiExplicitSkillsRuntimeCompleted - ?.hasAssistantSummary === - true || + .guiMcpStructuredContentCompleted + ?.hasReferenceId === + true) || + summary + .guiMcpStructuredContentCompleted + ?.hasDoneText === true || + summary + .guiMcpStructuredContentCompleted + ?.terminalDetailsCompacted === + true) && + summary + .guiMcpStructuredContentCompleted + ?.envelopeVisible === false + : isMediaReferenceScenario + ? pageText.includes( + MEDIA_REFERENCE_PROMPT, + ) && + pageText.includes( + MEDIA_REFERENCE_SUMMARY_TEXT, + ) && + pageText.includes( + MEDIA_REFERENCE_URI, + ) + : isSkillsRuntimeScenario + ? summary + .guiSkillsRuntimeCompleted + ?.hasPrompt === true && + (summary + .guiSkillsRuntimeCompleted + ?.hasAssistantSummary === + true || + summary + .guiSkillsRuntimeCompleted + ?.hasDoneText === + true) && summary .guiExplicitSkillsRuntimeCompleted - ?.hasDoneText === true) && - summary - .guiManualEnableSkillsRuntimeCompleted - ?.hasPrompt === true && - (summary - .guiManualEnableSkillsRuntimeCompleted - ?.hasAssistantSummary === - true || + ?.hasPrompt === true && + (summary + .guiExplicitSkillsRuntimeCompleted + ?.hasAssistantSummary === + true || + summary + .guiExplicitSkillsRuntimeCompleted + ?.hasDoneText === + true) && summary .guiManualEnableSkillsRuntimeCompleted - ?.hasDoneText === true) - : isAnyExpertSkillsRuntimeScenario - ? isExpertPanelSkillsRuntimeScenario - ? summary - .guiExpertPanelSkillsRuntimeCompleted - ?.hasPrompt === true && - (summary - .guiExpertPanelSkillsRuntimeCompleted - ?.hasAssistantSummary === - true || - summary + ?.hasPrompt === true && + (summary + .guiManualEnableSkillsRuntimeCompleted + ?.hasAssistantSummary === + true || + summary + .guiManualEnableSkillsRuntimeCompleted + ?.hasDoneText === true) + : isAnyExpertSkillsRuntimeScenario + ? isExpertPanelSkillsRuntimeScenario + ? summary .guiExpertPanelSkillsRuntimeCompleted - ?.hasDoneText === - true) && - pageText.includes( - EXPERT_SKILLS_RUNTIME_TITLE, - ) - : summary - .guiExpertSkillsRuntimeCompleted - ?.hasPrompt === true && - (summary - .guiExpertSkillsRuntimeCompleted - ?.hasAssistantSummary === - true || - summary + ?.hasPrompt === + true && + (summary + .guiExpertPanelSkillsRuntimeCompleted + ?.hasAssistantSummary === + true || + summary + .guiExpertPanelSkillsRuntimeCompleted + ?.hasDoneText === + true) && + pageText.includes( + EXPERT_SKILLS_RUNTIME_TITLE, + ) + : summary .guiExpertSkillsRuntimeCompleted - ?.hasDoneText === - true) && - pageText.includes( - EXPERT_SKILLS_RUNTIME_TITLE, - ) - : pageText.includes( - NEWS_PROMPT, - ) && - (pageText.includes( - "今日国际新闻简要整理", - ) || - pageText.includes( - ASSISTANT_DONE_TEXT, - )), + ?.hasPrompt === + true && + (summary + .guiExpertSkillsRuntimeCompleted + ?.hasAssistantSummary === + true || + summary + .guiExpertSkillsRuntimeCompleted + ?.hasDoneText === + true) && + pageText.includes( + EXPERT_SKILLS_RUNTIME_TITLE, + ) + : pageText.includes( + NEWS_PROMPT, + ) && + (pageText.includes( + "今日国际新闻简要整理", + ) || + pageText.includes( + ASSISTANT_DONE_TEXT, + )), noInvokeErrors: !errorRaw, noConsoleErrors: actionableConsoleErrors.length === 0, agentUiPerformanceTraceEvidenceAvailable: @@ -1016,7 +1067,8 @@ export function buildCommonAssertions(context) { agentUiPerformanceTraceSeparatesProviderAndClient: !shouldRequireTextStreamTraceSeparation || summary.agentUiPerformanceTraceDeferred != null || - (agentUiPerformanceTrace?.hasProviderWaitMs === true && + ((!shouldRequireProviderTextTraceEvidence || + agentUiPerformanceTrace?.hasProviderWaitMs === true) && agentUiPerformanceTrace?.hasClientLocalOutputMs === true), agentUiPerformanceTraceHasFirstVisibleTextPaint: !shouldRequireTextStreamTraceSeparation || @@ -1051,10 +1103,11 @@ export function buildCommonAssertions(context) { appServerTraceEvidenceSeparatesProviderAndServer: !shouldRequireTextStreamTraceSeparation || appServerTraceEvidenceDeferred || - (appServerTraceEvidence?.hasProviderFirstTextDelta === true && + ((!shouldRequireProviderTextTraceEvidence || + appServerTraceEvidence?.hasProviderFirstTextDelta === true) && appServerTraceEvidence?.hasAppServerMessageDelta === true), appServerTraceEvidenceHasProviderFirstTextCheckpoint: - !shouldRequireTextStreamTraceSeparation || + !shouldRequireProviderTextTraceEvidence || appServerTraceEvidenceDeferred || appServerTraceEvidence?.hasProviderFirstTextDelta === true, appServerTraceEvidenceHasMessageDeltaCheckpoint: @@ -1062,7 +1115,7 @@ export function buildCommonAssertions(context) { appServerTraceEvidenceDeferred || appServerTraceEvidence?.hasAppServerMessageDelta === true, appServerTraceEvidenceHasProviderWaitMs: - !shouldRequireTextStreamTraceSeparation || + !shouldRequireProviderTextTraceEvidence || appServerTraceEvidenceDeferred || appServerTraceEvidence?.hasProviderWaitMs === true, appServerTraceEvidenceHasServerEmissionTimestamp: diff --git a/scripts/agent-runtime/claw-chat-current-fixture-common-assertions.test.mjs b/scripts/agent-runtime/claw-chat-current-fixture-common-assertions.test.mjs new file mode 100644 index 000000000..cd7529bd3 --- /dev/null +++ b/scripts/agent-runtime/claw-chat-current-fixture-common-assertions.test.mjs @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { buildCommonAssertions } from "./claw-chat-current-fixture-common-assertions.mjs"; + +const SCENARIO_FLAGS = [ + "isCancelOnlyScenario", + "isCancelThenContinueScenario", + "isPlanScenario", + "isGoalScenario", + "isHomeHotpathScenario", + "isImageCommandScenario", + "isInputbarRichRestoreScenario", + "isActiveSteerScenario", + "isWebToolsRenderingScenario", + "isLiveTailCommitScenario", + "isElectronResizeReflowScenario", + "isMcpStructuredContentScenario", + "isMediaReferenceScenario", + "isReasoningFirstVisibleScenario", + "isTerminalCanceledAfterAnswerScenario", + "isTerminalFailedAfterAnswerScenario", + "isTerminalStaleGuardScenario", + "isTypedErrorRetryScenario", + "isTypedErrorRetrySuccessScenario", + "isSkillsRuntimeScenario", + "isSoulStyleScenario", + "isRightSurfaceVisualMatrixScenario", + "isContentFactoryArticleWorkspaceScenario", + "isAnyExpertSkillsRuntimeScenario", + "isApprovalRequestResumeScenario", + "isApprovalRequestDeclineScenario", + "isApprovalRequestCancelScenario", + "isApprovalRequestDecisionScenario", + "isApprovalRequestFullAccessScenario", + "isExpertPlazaSkillsRuntimeScenario", + "isExpertPanelSkillsRuntimeScenario", + "hasCancelPhase", +]; + +function buildContext({ typedError = false, summary = {} } = {}) { + return { + rendererSnapshot: {}, + appServerRequestMethods: [], + guiTurnStartReachedBackend: false, + backendLedger: [], + runtimeRequest: {}, + pageText: "", + errorRaw: null, + actionableConsoleErrors: [], + summary, + ...Object.fromEntries(SCENARIO_FLAGS.map((flag) => [flag, false])), + isTypedErrorRetryScenario: typedError, + isTypedErrorRetrySuccessScenario: typedError, + }; +} + +function buildTraceSummary({ + hasClientLocalOutputMs = true, + hasFirstVisibleOutputMs = true, + hasFirstTextDeltaToFirstTextPaintMs = true, + hasAppServerMessageDelta = true, + hasServerEventEmittedAt = true, + hasW3cTraceContext = true, +} = {}) { + return { + agentUiPerformanceTrace: { + available: true, + hasProviderWaitMs: false, + hasClientLocalOutputMs, + hasFirstVisibleOutputMs, + hasFirstTextDeltaToFirstTextPaintMs, + rawEntriesExported: false, + forbiddenFragmentPresent: false, + }, + appServerTraceEvidence: { + available: true, + hasProviderFirstTextDelta: false, + hasAppServerMessageDelta, + hasProviderWaitMs: false, + hasServerEventEmittedAt, + hasW3cTraceContext, + forbiddenFragmentPresent: false, + }, + }; +} + +describe("claw chat current fixture common trace assertions", () => { + it("requires provider first-text evidence for ordinary text streaming", () => { + const assertions = buildCommonAssertions( + buildContext({ summary: buildTraceSummary() }), + ); + + expect(assertions.agentUiPerformanceTraceSeparatesProviderAndClient).toBe( + false, + ); + expect(assertions.appServerTraceEvidenceSeparatesProviderAndServer).toBe( + false, + ); + expect( + assertions.appServerTraceEvidenceHasProviderFirstTextCheckpoint, + ).toBe(false); + expect(assertions.appServerTraceEvidenceHasProviderWaitMs).toBe(false); + expect(assertions.appServerTraceEvidenceHasMessageDeltaCheckpoint).toBe( + true, + ); + expect( + assertions.appServerTraceEvidenceHasServerEmissionTimestamp, + ).toBe(true); + }); + + it("does not require provider first-text evidence for typed error retries", () => { + const assertions = buildCommonAssertions( + buildContext({ typedError: true, summary: buildTraceSummary() }), + ); + + expect(assertions.agentUiPerformanceTraceSeparatesProviderAndClient).toBe( + true, + ); + expect(assertions.appServerTraceEvidenceSeparatesProviderAndServer).toBe( + true, + ); + expect( + assertions.appServerTraceEvidenceHasProviderFirstTextCheckpoint, + ).toBe(true); + expect(assertions.appServerTraceEvidenceHasProviderWaitMs).toBe(true); + expect(assertions.appServerTraceEvidenceHasMessageDeltaCheckpoint).toBe( + true, + ); + expect( + assertions.appServerTraceEvidenceHasServerEmissionTimestamp, + ).toBe(true); + }); + + it("keeps non-provider trace evidence mandatory for typed error retries", () => { + const assertions = buildCommonAssertions( + buildContext({ + typedError: true, + summary: buildTraceSummary({ + hasClientLocalOutputMs: false, + hasFirstVisibleOutputMs: false, + hasFirstTextDeltaToFirstTextPaintMs: false, + hasAppServerMessageDelta: false, + hasServerEventEmittedAt: false, + hasW3cTraceContext: false, + }), + }), + ); + + expect(assertions.agentUiPerformanceTraceSeparatesProviderAndClient).toBe( + false, + ); + expect(assertions.agentUiPerformanceTraceHasFirstVisibleTextPaint).toBe( + false, + ); + expect(assertions.appServerTraceEvidenceSeparatesProviderAndServer).toBe( + false, + ); + expect(assertions.appServerTraceEvidenceHasMessageDeltaCheckpoint).toBe( + false, + ); + expect( + assertions.appServerTraceEvidenceHasServerEmissionTimestamp, + ).toBe(false); + expect(assertions.appServerTraceEvidenceUsesCurrentMethods).toBe(false); + expect(assertions.appServerTraceEvidenceHasW3cCarrier).toBe(false); + }); +}); diff --git a/scripts/agent-runtime/claw-chat-current-fixture-constants.mjs b/scripts/agent-runtime/claw-chat-current-fixture-constants.mjs index b7f91b7c8..d3b4918cd 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-constants.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-constants.mjs @@ -167,6 +167,11 @@ export const INPUTBAR_RICH_RESTORE_FORBIDDEN_ASSISTANT_TEXT = "CLAW_INPUTBAR_RICH_RESTORE_DONE"; export const CONTINUE_DONE_TEXT = "CLAW_CONTINUE_FIXTURE_DONE"; export const PLAN_DONE_TEXT = "CLAW_PLAN_FIXTURE_DONE"; +export const TURN_PLAN_UPDATE_SCENARIO = "turn-plan-update"; +export const TURN_PLAN_UPDATE_PROMPT = "请先更新执行清单,再给出本轮处理结果"; +export const TURN_PLAN_UPDATE_DONE_TEXT = "CLAW_TURN_PLAN_UPDATE_DONE"; +export const TURN_PLAN_UPDATE_EXPLANATION = "继续执行"; +export const TURN_PLAN_UPDATE_TOOL_CALL_ID = "call_turn_plan_update"; export const GOAL_DONE_TEXT = "CLAW_GOAL_FIXTURE_DONE"; export const WEB_TOOLS_RENDERING_DONE_TEXT = "CLAW_WEB_TOOLS_RENDERING_DONE"; export const MCP_STRUCTURED_CONTENT_DONE_TEXT = @@ -277,6 +282,36 @@ export const TERMINAL_CANCELED_AFTER_ANSWER_PARTIAL_TEXT = "TERMINAL_CANCELED_AFTER_ANSWER_PARTIAL: 正文已经先显示,取消终态不能覆盖或重复。"; export const TERMINAL_CANCELED_AFTER_ANSWER_CANCELED_TEXT = "TERMINAL_CANCELED_AFTER_ANSWER_CANCELED: user canceled after partial answer"; +export const TYPED_ERROR_RETRY_SUCCESS_SCENARIO = "typed-error-retry-success"; +export const TYPED_ERROR_RETRY_FAILURE_SCENARIO = "typed-error-retry-failure"; +export const TYPED_ERROR_RETRY_SUCCESS_PROMPT = "验证插件重试后继续完成"; +export const TYPED_ERROR_RETRY_FAILURE_PROMPT = "验证重试耗尽后进入失败终态"; +export const TYPED_ERROR_RETRY_MESSAGE = "插件暂时不可用,正在重试当前回合。"; +export const TYPED_ERROR_RETRY_SECOND_MESSAGE = + "第二次插件重试仍在进行,当前回合保持打开。"; +export const TYPED_ERROR_RETRY_SUCCESS_TEXT = + "重试完成后,当前回合继续输出并正常收口。"; +export const TYPED_ERROR_RETRY_SUCCESS_DONE_TEXT = + "TYPED_ERROR_RETRY_SUCCESS_DONE"; +export const TYPED_ERROR_RETRY_FAILURE_TEXT = + "重试次数已耗尽,当前回合保留失败记录。"; +export const TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT = + "失败终态前已保留的部分输出。"; +export const TYPED_ERROR_RETRY_FAILURE_ERROR_TEXT = + "TYPED_ERROR_RETRY_FAILURE_ERROR: plugin worker retry budget exhausted"; +export const TYPED_ERROR_RETRY_ASSERTION_KEYS = [ + "typedErrorRetryPromptReachedBackend", + "typedErrorRetryBackendEventOrder", + "typedErrorRetryGuiRetryingStatusVisible", + "typedErrorRetryNoPrematureTerminal", + "typedErrorRetrySuccessGuiCompleted", + "typedErrorRetrySuccessReadModelCompleted", + "typedErrorRetryFailureGuiAwaitedTerminal", + "typedErrorRetryFailureGuiCompleted", + "typedErrorRetryFailureReadModelFailed", + "typedErrorRetryFailureNoPrematureTerminal", + "typedErrorRetryIdentityConsistent", +]; export const WEB_TOOLS_REASONING_ITEM_SIGNATURE = "web-tools-reasoning-item-signature"; export const WEB_TOOLS_REASONING_NATIVE_ITEM_ID = "rs_web_tools_fixture"; @@ -298,6 +333,10 @@ export const PLAN_STEPS = [ { step: "输出 proposed_plan", status: "in_progress" }, { step: "验证右侧计划轨显示", status: "pending" }, ]; +export const TURN_PLAN_UPDATE_STEPS = [ + { step: "读现状", status: "completed" }, + { step: "补主链", status: "in_progress" }, +]; export const PROPOSED_PLAN_BLOCK = ` ${PLAN_STEPS.map((step) => `- ${step.step}`).join("\n")} `; diff --git a/scripts/agent-runtime/claw-chat-current-fixture-inputbar-rich-restore.mjs b/scripts/agent-runtime/claw-chat-current-fixture-inputbar-rich-restore.mjs index 8375fb157..9299adfc5 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-inputbar-rich-restore.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-inputbar-rich-restore.mjs @@ -69,7 +69,12 @@ function recordRichRestoreStep(summary, stage, value) { ); } -export async function waitForRichRestoreSnapshot(page, options, predicate, label) { +export async function waitForRichRestoreSnapshot( + page, + options, + predicate, + label, +) { const startedAt = Date.now(); let lastSnapshot = null; while (Date.now() - startedAt < options.timeoutMs) { @@ -84,9 +89,7 @@ export async function waitForRichRestoreSnapshot(page, options, predicate, label } await sleep(options.intervalMs); } - throw new Error( - `${label}: ${JSON.stringify(sanitizeJson(lastSnapshot))}`, - ); + throw new Error(`${label}: ${JSON.stringify(sanitizeJson(lastSnapshot))}`); } async function evaluateRichRestoreSnapshot(page) { @@ -100,11 +103,19 @@ async function evaluateRichRestoreSnapshot(page) { sessionId, skillName, }) => { + const normalizeMentionText = (value) => + String(value || "") + .trim() + .toLocaleLowerCase() + .replace(/[\s_-]+/g, ""); + const expectedSkillName = normalizeMentionText(skillName); const textareaCandidates = Array.from( document.querySelectorAll('textarea[name="agent-chat-message"]'), ).filter((node) => node instanceof HTMLTextAreaElement); const textarea = - textareaCandidates.find((node) => node.dataset.sessionId === sessionId) ?? + textareaCandidates.find( + (node) => node.dataset.sessionId === sessionId, + ) ?? textareaCandidates[0] ?? null; const container = textarea?.closest( @@ -114,11 +125,11 @@ async function evaluateRichRestoreSnapshot(page) { const style = textarea ? window.getComputedStyle(textarea) : null; const textareaVisible = Boolean( textarea && - rect && - rect.width > 16 && - rect.height > 16 && - style?.visibility !== "hidden" && - style?.display !== "none", + rect && + rect.width > 16 && + rect.height > 16 && + style?.visibility !== "hidden" && + style?.display !== "none", ); const buttons = Array.from(document.querySelectorAll("button")).map( (button) => ({ @@ -221,7 +232,9 @@ async function evaluateRichRestoreSnapshot(page) { imageRestored: imagePreviewCount > 0, skillBadgeCount: skillBadges.length, skillBadgeTexts: skillBadges, - skillRestored: skillBadges.some((text) => text.includes(skillName)), + skillRestored: skillBadges.some((text) => + normalizeMentionText(text).includes(expectedSkillName), + ), stopButtonVisible, sendButtonExists: Boolean(sendButton), sendButtonDisabled: @@ -317,8 +330,7 @@ async function dropPathReference(page, options) { (node) => node instanceof HTMLTextAreaElement && node.dataset.sessionId === sessionId, - ) ?? - document.querySelector('textarea[name="agent-chat-message"]'); + ) ?? document.querySelector('textarea[name="agent-chat-message"]'); const target = textarea?.closest('[data-testid="inputbar-core-container"]') ?? textarea; @@ -418,23 +430,46 @@ async function selectCapabilityReportSkill(page, options) { `textarea[name="agent-chat-message"][data-session-id="${richRestoreSessionId}"]`, ); await textarea.fill("/capability-report"); + let lastSelectionResult = null; const selected = await waitForRichRestoreSnapshot( page, { ...options, timeoutMs: Math.min(options.timeoutMs, 30_000) }, async () => { const result = await page.evaluate((skillName) => { + const normalizeMentionText = (value) => + String(value || "") + .trim() + .toLocaleLowerCase() + .replace(/[\s_-]+/g, ""); + const expectedSkillName = normalizeMentionText(skillName); const popover = document.querySelector( '[data-testid="mention-popover-content"]', ); - if (!popover || !popover.textContent?.includes(skillName)) { - return { clicked: false, reason: "skill-not-visible" }; + if ( + !popover || + !normalizeMentionText(popover.textContent).includes(expectedSkillName) + ) { + return { + clicked: false, + reason: "skill-not-visible", + candidates: popover + ? Array.from( + popover.querySelectorAll('[cmdk-item], [role="option"]'), + ) + .map((node) => String(node.textContent || "").trim()) + .filter(Boolean) + .slice(0, 8) + : [], + }; } const items = Array.from( popover.querySelectorAll('[cmdk-item], [role="option"]'), ).filter( (node) => node instanceof HTMLElement && - (node.textContent || "").includes(skillName) && + normalizeMentionText(node.textContent).includes( + expectedSkillName, + ) && node.getAttribute("aria-disabled") !== "true" && !(node instanceof HTMLButtonElement && node.disabled), ); @@ -474,15 +509,16 @@ async function selectCapabilityReportSkill(page, options) { candidateCount: items.length, }; }, INPUTBAR_RICH_RESTORE_SKILL_NAME); + lastSelectionResult = sanitizeJson(result); return result?.clicked === true; }, "Inputbar mention 面板未选中 Capability Report 技能", ).catch(async (error) => { const debug = await evaluateRichRestoreSnapshot(page); throw new Error( - `${error instanceof Error ? error.message : String(error)}; snapshot=${JSON.stringify( - sanitizeJson(debug), - )}`, + `${error instanceof Error ? error.message : String(error)}; selection=${JSON.stringify( + lastSelectionResult, + )}; snapshot=${JSON.stringify(sanitizeJson(debug))}`, ); }); void selected; @@ -682,14 +718,12 @@ export async function runInputbarRichRestoreScenario({ return sanitizeJson({ inputbarRichRestoreSkill: summary.inputbarRichRestoreSkill, - inputbarRichRestoreDraftPrepared: - summary.inputbarRichRestoreDraftPrepared, + inputbarRichRestoreDraftPrepared: summary.inputbarRichRestoreDraftPrepared, inputbarRichRestoreInputSend: summary.inputbarRichRestoreInputSend, inputbarRichRestoreBackendTurnStart: summary.inputbarRichRestoreBackendTurnStart, inputbarRichRestoreStopClick: summary.inputbarRichRestoreStopClick, - inputbarRichRestoreBackendCancel: - summary.inputbarRichRestoreBackendCancel, + inputbarRichRestoreBackendCancel: summary.inputbarRichRestoreBackendCancel, inputbarRichRestoreGuiCanceled: summary.inputbarRichRestoreGuiCanceled, inputbarRichRestoreReadModelCanceled: summary.inputbarRichRestoreReadModelCanceled, diff --git a/scripts/agent-runtime/claw-chat-current-fixture-not-applicable-assertions.mjs b/scripts/agent-runtime/claw-chat-current-fixture-not-applicable-assertions.mjs index 44533a098..8e0e09772 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-not-applicable-assertions.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-not-applicable-assertions.mjs @@ -17,6 +17,7 @@ import { TERMINAL_CANCELED_AFTER_ANSWER_ASSERTION_KEYS, TERMINAL_FAILED_AFTER_ANSWER_ASSERTION_KEYS, TERMINAL_STALE_GUARD_ASSERTION_KEYS, + TYPED_ERROR_RETRY_ASSERTION_KEYS, WEB_TOOLS_RENDERING_ASSERTION_KEYS, } from "./claw-chat-current-fixture-constants.mjs"; import { MEDIA_REFERENCE_ASSERTION_KEYS } from "./claw-chat-current-fixture-media-reference.mjs"; @@ -49,6 +50,7 @@ export function buildNotApplicableAssertions(context) { isTerminalCanceledAfterAnswerScenario, isTerminalFailedAfterAnswerScenario, isTerminalStaleGuardScenario, + isTypedErrorRetryScenario, isWebToolsRenderingScenario, } = context; if ( @@ -622,5 +624,6 @@ export function buildNotApplicableAssertions(context) { ...(isTerminalStaleGuardScenario ? [] : TERMINAL_STALE_GUARD_ASSERTION_KEYS), + ...(isTypedErrorRetryScenario ? [] : TYPED_ERROR_RETRY_ASSERTION_KEYS), ]; } diff --git a/scripts/agent-runtime/claw-chat-current-fixture-read-model-waits.mjs b/scripts/agent-runtime/claw-chat-current-fixture-read-model-waits.mjs index 38979e054..e3d1838b8 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-read-model-waits.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-read-model-waits.mjs @@ -118,6 +118,7 @@ export async function waitForSessionReadCompleted( lastRead = read.result; const serialized = JSON.stringify(read.result || {}); if ( + readModelLatestTurnStatus(read.result) === "completed" && serialized.includes(prompt) && (serialized.includes(doneText) || serialized.includes(summaryText)) ) { diff --git a/scripts/agent-runtime/claw-chat-current-fixture-rpc.mjs b/scripts/agent-runtime/claw-chat-current-fixture-rpc.mjs index 967dc868b..3dd0e713e 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-rpc.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-rpc.mjs @@ -32,6 +32,7 @@ import { const APP_SERVER_DIRECT_EVENT_TYPES = new Map([ ["turn/started", "turn.started"], + ["turn/plan/updated", "turn.plan.updated"], ["turn/completed", "turn.completed"], ["item/started", "item.started"], ["item/completed", "item.completed"], diff --git a/scripts/agent-runtime/claw-chat-current-fixture-scenario-assertions.mjs b/scripts/agent-runtime/claw-chat-current-fixture-scenario-assertions.mjs index 422a2dffc..050d8bb16 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-scenario-assertions.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-scenario-assertions.mjs @@ -43,6 +43,7 @@ import { } from "./claw-chat-current-fixture-skills-runtime-assertions.mjs"; import { buildElectronResizeReflowScenarioAssertions } from "./claw-chat-current-fixture-resize-reflow-assertions.mjs"; import { buildTerminalScenarioAssertions } from "./claw-chat-current-fixture-terminal-assertions.mjs"; +import { buildTypedErrorScenarioAssertions } from "./claw-chat-current-fixture-typed-error.mjs"; import { buildWebToolsRenderingScenarioAssertions } from "./claw-chat-current-fixture-web-tools-assertions.mjs"; import { buildSoulStyleScenarioAssertions } from "./claw-chat-current-fixture-soul-style.mjs"; import { buildActiveSteerScenarioAssertions } from "./claw-chat-current-fixture-active-steer.mjs"; @@ -313,6 +314,9 @@ export function buildScenarioAssertions(context) { isReasoningFirstVisibleScenario, isTerminalCanceledAfterAnswerScenario, isTerminalFailedAfterAnswerScenario, + isTypedErrorRetryFailureScenario, + isTypedErrorRetrySuccessScenario, + isTypedErrorRetryScenario, isRightSurfaceVisualMatrixScenario, isSkillsRuntimeScenario, isSoulStyleScenario, @@ -338,6 +342,7 @@ export function buildScenarioAssertions(context) { terminalCanceledAfterAnswerTurnStart, terminalFailedAfterAnswerTurnStart, terminalStaleGuardFirstTurnStart, + typedErrorTurnStart, terminalStaleGuardSecondTurnStart, webToolsRenderingTurnStart, reasoningFirstVisibleTurnStart, @@ -987,376 +992,388 @@ export function buildScenarioAssertions(context) { summary, }, ) - : isTerminalStaleGuardScenario || - isTerminalCanceledAfterAnswerScenario || - isTerminalFailedAfterAnswerScenario - ? buildTerminalScenarioAssertions({ - isTerminalCanceledAfterAnswerScenario, - isTerminalFailedAfterAnswerScenario, - isTerminalStaleGuardScenario, + : isTypedErrorRetryScenario + ? buildTypedErrorScenarioAssertions({ + isTypedErrorRetryFailureScenario, + isTypedErrorRetrySuccessScenario, summary, - terminalCanceledAfterAnswerTurnStart, - terminalFailedAfterAnswerTurnStart, - terminalStaleGuardFirstTurnStart, - terminalStaleGuardSecondTurnStart, + typedErrorTurnStart, }) - : isMcpStructuredContentScenario - ? buildMcpStructuredContentScenarioAssertions( - { - mcpStructuredContentTurnStart, - summary, - }, - ) - : isMediaReferenceScenario - ? buildMediaReferenceScenarioAssertions( + : isTerminalStaleGuardScenario || + isTerminalCanceledAfterAnswerScenario || + isTerminalFailedAfterAnswerScenario + ? buildTerminalScenarioAssertions({ + isTerminalCanceledAfterAnswerScenario, + isTerminalFailedAfterAnswerScenario, + isTerminalStaleGuardScenario, + summary, + terminalCanceledAfterAnswerTurnStart, + terminalFailedAfterAnswerTurnStart, + terminalStaleGuardFirstTurnStart, + terminalStaleGuardSecondTurnStart, + }) + : isMcpStructuredContentScenario + ? buildMcpStructuredContentScenarioAssertions( { - mediaReferenceTurnStart, - pageText, + mcpStructuredContentTurnStart, summary, }, ) - : isSkillsRuntimeScenario - ? buildSkillsRuntimeScenarioAssertions( + : isMediaReferenceScenario + ? buildMediaReferenceScenarioAssertions( { - explicitSkillsRuntimeTurnStart, - manualEnableRuntimeBinding, - manualEnableRuntimeMetadata, - manualEnableSkillsRuntimeTurnStart, - skillsRuntimeTurnStart, + mediaReferenceTurnStart, + pageText, summary, - workspace, }, ) - : isAnyExpertSkillsRuntimeScenario - ? buildExpertSkillsRuntimeScenarioAssertions( + : isSkillsRuntimeScenario + ? buildSkillsRuntimeScenarioAssertions( { - expectedExpertHarnessSkillRef, - expertHarnessMetadata, - expertHarnessSkillRefs, - expertPanelSkillsRuntimeTurnStart, - expertRuntimeMetadata, - expertSkillsRuntimeTurnStart, - isExpertPanelSkillsRuntimeScenario, - isExpertPlazaSkillsRuntimeScenario, + explicitSkillsRuntimeTurnStart, + manualEnableRuntimeBinding, + manualEnableRuntimeMetadata, + manualEnableSkillsRuntimeTurnStart, + skillsRuntimeTurnStart, summary, + workspace, }, ) - : hasCancelPhase - ? { - usedCurrentTurnCancel: - appServerRequestMethods.includes( - APP_SERVER_METHOD_SESSION_TURN_CANCEL, - ), - externalFixtureCancelUsed: - backendLedger.some( - (entry) => - entry.kind === - "turnCancel", - ), - fixtureCancelReachedBackend: - latestTurnCancel?.sessionId === - summary.sessionId && - typeof latestTurnCancel?.turnId === - "string" && - latestTurnCancel.turnId.trim() - .length > 0, - guiStopClicked: - summary.stopClick?.clicked - ?.clicked === true, - readModelCanceled: - summary.readModelCanceled - ?.includesPrompt === - true && - summary.readModelCanceled - ?.hasInterruptedTurn === - true, - ...(isCancelThenContinueScenario - ? { - continuePromptReachedBackend: - continueTurnStart?.inputText === - CONTINUE_PROMPT, - guiContinueInputSubmitted: - summary - .continueInputSend - ?.afterFill - ?.promptVisibleInTextarea === - true && - summary - .continueInputSend - ?.clicked - ?.clicked === true, - guiContinueCompleted: - summary - .guiContinueCompleted - ?.hasPrompt === - true && - (summary - .guiContinueCompleted - ?.hasAssistantSummary === - true || - summary - .guiContinueCompleted - ?.hasDoneText === - true) && - summary - .guiContinueCompleted - ?.textareaVisible === - true && - summary - .guiContinueCompleted - ?.textareaDisabled === - false && - summary - .guiContinueCompleted - ?.stopButtonVisible === - false, - readModelContinueCompleted: - summary - .readModelContinueCompleted - ?.includesPrompt === - true && - (summary - .readModelContinueCompleted - ?.includesAssistantDone === - true || - summary - .readModelContinueCompleted - ?.includesAssistantSummary === - true), - backendRecordedCancelThenContinue: - backendLedger.filter( - (entry) => - entry.kind === - "turnStart", - ).length >= 2 && - backendLedger.some( - (entry) => - entry.kind === - "turnCancel", - ), - } - : {}), - } - : isInputbarRichRestoreScenario + : isAnyExpertSkillsRuntimeScenario + ? buildExpertSkillsRuntimeScenarioAssertions( + { + expectedExpertHarnessSkillRef, + expertHarnessMetadata, + expertHarnessSkillRefs, + expertPanelSkillsRuntimeTurnStart, + expertRuntimeMetadata, + expertSkillsRuntimeTurnStart, + isExpertPanelSkillsRuntimeScenario, + isExpertPlazaSkillsRuntimeScenario, + summary, + }, + ) + : hasCancelPhase ? { - inputbarRichRestorePromptReachedBackend: - String( - inputbarRichRestoreTurnStart?.inputText || - "", - ).includes( - INPUTBAR_RICH_RESTORE_PROMPT, - ), - inputbarRichRestoreDraftPrepared: - summary - .inputbarRichRestoreDraftPrepared - ?.prepared - ?.imageRestored === - true && - summary - .inputbarRichRestoreDraftPrepared - ?.prepared - ?.pathRestored === - true && - summary - .inputbarRichRestoreDraftPrepared - ?.prepared - ?.skillRestored === - true, - inputbarRichRestoreInputSubmitted: - summary - .inputbarRichRestoreInputSend - ?.afterFill - ?.promptVisibleInTextarea === - true && - summary - .inputbarRichRestoreInputSend - ?.clicked?.clicked === - true, - inputbarRichRestoreBackendInputSummaryReached: - summary - .inputbarRichRestoreBackendTurnStart - ?.inputSummary - ?.imageAttachmentCount >= - 1 && - summary - .inputbarRichRestoreBackendTurnStart - ?.inputSummary - ?.fileReferenceCount >= - 1 && - summary.inputbarRichRestoreBackendTurnStart?.inputSummary?.fileReferenceNames?.includes( - INPUTBAR_RICH_RESTORE_PATH_NAME, - ) === true, - inputbarRichRestoreUsedCurrentTurnCancel: + usedCurrentTurnCancel: appServerRequestMethods.includes( APP_SERVER_METHOD_SESSION_TURN_CANCEL, ), - inputbarRichRestoreBackendCanceled: + externalFixtureCancelUsed: + backendLedger.some( + (entry) => + entry.kind === + "turnCancel", + ), + fixtureCancelReachedBackend: latestTurnCancel?.sessionId === - inputbarRichRestoreTurnStart?.sessionId && - latestTurnCancel?.turnId === - inputbarRichRestoreTurnStart?.turnId && + summary.sessionId && typeof latestTurnCancel?.turnId === "string" && latestTurnCancel.turnId.trim() .length > 0, - inputbarRichRestoreGuiCanceled: - summary - .inputbarRichRestoreGuiCanceled - ?.stopButtonVisible === - false && - summary - .inputbarRichRestoreGuiCanceled - ?.textareaDisabled === - false, - inputbarRichRestoreTextRestored: - summary - .inputbarRichRestoreGuiCanceled - ?.textareaValue === - INPUTBAR_RICH_RESTORE_PROMPT, - inputbarRichRestoreImageRestored: - summary - .inputbarRichRestoreGuiCanceled - ?.imageRestored === - true, - inputbarRichRestorePathRestored: - summary - .inputbarRichRestoreGuiCanceled - ?.pathRestored === true, - inputbarRichRestoreSkillRestored: - summary - .inputbarRichRestoreGuiCanceled - ?.skillRestored === - true, - inputbarRichRestoreNoVisibleAssistantOutput: - summary - .inputbarRichRestoreGuiCanceled - ?.noVisibleAssistantOutput === - true, - inputbarRichRestoreReadModelCanceled: - summary - .inputbarRichRestoreReadModelCanceled + guiStopClicked: + summary.stopClick?.clicked + ?.clicked === true, + readModelCanceled: + summary.readModelCanceled ?.includesPrompt === true && - summary - .inputbarRichRestoreReadModelCanceled - ?.includesCanceled === - true && - summary - .inputbarRichRestoreReadModelCanceled - ?.forbiddenAssistantOutput === - false, + summary.readModelCanceled + ?.hasInterruptedTurn === + true, + ...(isCancelThenContinueScenario + ? { + continuePromptReachedBackend: + continueTurnStart?.inputText === + CONTINUE_PROMPT, + guiContinueInputSubmitted: + summary + .continueInputSend + ?.afterFill + ?.promptVisibleInTextarea === + true && + summary + .continueInputSend + ?.clicked + ?.clicked === + true, + guiContinueCompleted: + summary + .guiContinueCompleted + ?.hasPrompt === + true && + (summary + .guiContinueCompleted + ?.hasAssistantSummary === + true || + summary + .guiContinueCompleted + ?.hasDoneText === + true) && + summary + .guiContinueCompleted + ?.textareaVisible === + true && + summary + .guiContinueCompleted + ?.textareaDisabled === + false && + summary + .guiContinueCompleted + ?.stopButtonVisible === + false, + readModelContinueCompleted: + summary + .readModelContinueCompleted + ?.includesPrompt === + true && + (summary + .readModelContinueCompleted + ?.includesAssistantDone === + true || + summary + .readModelContinueCompleted + ?.includesAssistantSummary === + true), + backendRecordedCancelThenContinue: + backendLedger.filter( + (entry) => + entry.kind === + "turnStart", + ).length >= 2 && + backendLedger.some( + (entry) => + entry.kind === + "turnCancel", + ), + } + : {}), } - : { - noEpochFallbackTitle: - summary.guiCompleted - ?.hasEpochFallbackTitle === - false, - readModelCompleted: - summary.readModelCompleted - ?.includesPrompt === - true && - (summary - .readModelCompleted - ?.includesAssistantDone === - true || + : isInputbarRichRestoreScenario + ? { + inputbarRichRestorePromptReachedBackend: + String( + inputbarRichRestoreTurnStart?.inputText || + "", + ).includes( + INPUTBAR_RICH_RESTORE_PROMPT, + ), + inputbarRichRestoreDraftPrepared: + summary + .inputbarRichRestoreDraftPrepared + ?.prepared + ?.imageRestored === + true && + summary + .inputbarRichRestoreDraftPrepared + ?.prepared + ?.pathRestored === + true && + summary + .inputbarRichRestoreDraftPrepared + ?.prepared + ?.skillRestored === + true, + inputbarRichRestoreInputSubmitted: + summary + .inputbarRichRestoreInputSend + ?.afterFill + ?.promptVisibleInTextarea === + true && + summary + .inputbarRichRestoreInputSend + ?.clicked?.clicked === + true, + inputbarRichRestoreBackendInputSummaryReached: + summary + .inputbarRichRestoreBackendTurnStart + ?.inputSummary + ?.imageAttachmentCount >= + 1 && + summary + .inputbarRichRestoreBackendTurnStart + ?.inputSummary + ?.fileReferenceCount >= + 1 && + summary.inputbarRichRestoreBackendTurnStart?.inputSummary?.fileReferenceNames?.includes( + INPUTBAR_RICH_RESTORE_PATH_NAME, + ) === true, + inputbarRichRestoreUsedCurrentTurnCancel: + appServerRequestMethods.includes( + APP_SERVER_METHOD_SESSION_TURN_CANCEL, + ), + inputbarRichRestoreBackendCanceled: + latestTurnCancel?.sessionId === + inputbarRichRestoreTurnStart?.sessionId && + latestTurnCancel?.turnId === + inputbarRichRestoreTurnStart?.turnId && + typeof latestTurnCancel?.turnId === + "string" && + latestTurnCancel.turnId.trim() + .length > 0, + inputbarRichRestoreGuiCanceled: + summary + .inputbarRichRestoreGuiCanceled + ?.stopButtonVisible === + false && + summary + .inputbarRichRestoreGuiCanceled + ?.textareaDisabled === + false, + inputbarRichRestoreTextRestored: + summary + .inputbarRichRestoreGuiCanceled + ?.textareaValue === + INPUTBAR_RICH_RESTORE_PROMPT, + inputbarRichRestoreImageRestored: + summary + .inputbarRichRestoreGuiCanceled + ?.imageRestored === + true, + inputbarRichRestorePathRestored: + summary + .inputbarRichRestoreGuiCanceled + ?.pathRestored === + true, + inputbarRichRestoreSkillRestored: + summary + .inputbarRichRestoreGuiCanceled + ?.skillRestored === + true, + inputbarRichRestoreNoVisibleAssistantOutput: + summary + .inputbarRichRestoreGuiCanceled + ?.noVisibleAssistantOutput === + true, + inputbarRichRestoreReadModelCanceled: + summary + .inputbarRichRestoreReadModelCanceled + ?.includesPrompt === + true && + summary + .inputbarRichRestoreReadModelCanceled + ?.includesCanceled === + true && + summary + .inputbarRichRestoreReadModelCanceled + ?.forbiddenAssistantOutput === + false, + } + : { + noEpochFallbackTitle: + summary.guiCompleted + ?.hasEpochFallbackTitle === + false, + readModelCompleted: summary .readModelCompleted - ?.includesAssistantSummary === - true), - eventReadProbeObserved: - summary.eventReadProbe - ?.events - ?.hasTextDelta === - true && - summary.eventReadProbe - ?.events - ?.hasToolStarted === - true && - summary.eventReadProbe - ?.events - ?.hasToolResult === - true && - summary.eventReadProbe - ?.events - ?.hasTerminal === - true && - summary.eventReadProbe - ?.events?.eventTurnIds - ?.length === 1 && - summary.eventReadProbe - ?.events - ?.eventTurnIds?.[0] === + ?.includesPrompt === + true && + (summary + .readModelCompleted + ?.includesAssistantDone === + true || + summary + .readModelCompleted + ?.includesAssistantSummary === + true), + eventReadProbeObserved: summary.eventReadProbe - ?.turnId, - readModelEventReadAligned: - summary.eventReadProbe - ?.readModel - ?.containsTurnId === - true && - summary.eventReadProbe - ?.readModel - ?.containsReadText === - true, - readModelToolCallAligned: - summary.eventReadProbe - ?.readModel - ?.containsToolCall === - true && - summary.eventReadProbe - ?.readModel - ?.toolName === - EVENT_READ_PROBE_TOOL_NAME && - summary.eventReadProbe - ?.readModel - ?.toolStatus === - "completed" && - summary.eventReadProbe - ?.readModel - ?.containsToolOutput === - true && - summary.eventReadProbe - ?.readModel - ?.toolTurnId === + ?.events + ?.hasTextDelta === + true && summary.eventReadProbe - ?.turnId, - guiNoPlanUiWithoutProposedPlan: - summary.guiCompleted - ?.planUiAbsentWithoutProposedPlan === - true && - summary.guiCompleted - ?.planUiAbsence - ?.planOwnerCount === - 0 && - summary.guiCompleted - ?.planUiAbsence - ?.planDecisionVisible === - false && - (summary.guiCompleted - ?.planUiAbsence - ?.legacyUpdatePlanVisibleHits - ?.length ?? 0) === 0, - streamParserCompletedFullTextObserved: - streamParserBoundaryBackendObserved( - backendLedger, - ), - guiStreamParserNoDuplicateFinalText: - summary.guiCompleted - ?.assistantScopeSummaryOccurrences === - 1 && - ( + ?.events + ?.hasToolStarted === + true && + summary.eventReadProbe + ?.events + ?.hasToolResult === + true && + summary.eventReadProbe + ?.events + ?.hasTerminal === + true && + summary.eventReadProbe + ?.events?.eventTurnIds + ?.length === 1 && + summary.eventReadProbe + ?.events + ?.eventTurnIds?.[0] === + summary.eventReadProbe + ?.turnId, + readModelEventReadAligned: + summary.eventReadProbe + ?.readModel + ?.containsTurnId === + true && + summary.eventReadProbe + ?.readModel + ?.containsReadText === + true, + readModelToolCallAligned: + summary.eventReadProbe + ?.readModel + ?.containsToolCall === + true && + summary.eventReadProbe + ?.readModel + ?.toolName === + EVENT_READ_PROBE_TOOL_NAME && + summary.eventReadProbe + ?.readModel + ?.toolStatus === + "completed" && + summary.eventReadProbe + ?.readModel + ?.containsToolOutput === + true && + summary.eventReadProbe + ?.readModel + ?.toolTurnId === + summary.eventReadProbe + ?.turnId, + guiNoPlanUiWithoutProposedPlan: summary.guiCompleted - ?.assistantScopeDedupeGuardHits ?? - [] - ).every( - (hit) => - hit.occurrences === 1, - ), - readModelStreamParserNoDuplicateFinalText: - summary.readModelCompleted - ?.streamParserBoundary - ?.noDuplicateFinalText === - true, - }; + ?.planUiAbsentWithoutProposedPlan === + true && + summary.guiCompleted + ?.planUiAbsence + ?.planOwnerCount === + 0 && + summary.guiCompleted + ?.planUiAbsence + ?.planDecisionVisible === + false && + (summary.guiCompleted + ?.planUiAbsence + ?.legacyUpdatePlanVisibleHits + ?.length ?? 0) === 0, + streamParserCompletedFullTextObserved: + streamParserBoundaryBackendObserved( + backendLedger, + ), + guiStreamParserNoDuplicateFinalText: + summary.guiCompleted + ?.assistantScopeSummaryOccurrences === + 1 && + ( + summary.guiCompleted + ?.assistantScopeDedupeGuardHits ?? + [] + ).every( + (hit) => + hit.occurrences === + 1, + ), + readModelStreamParserNoDuplicateFinalText: + summary + .readModelCompleted + ?.streamParserBoundary + ?.noDuplicateFinalText === + true, + }; const userShellGate = summary.userShellGate; const userShellAssertions = userShellGate ? { diff --git a/scripts/agent-runtime/claw-chat-current-fixture-scenario-flow.mjs b/scripts/agent-runtime/claw-chat-current-fixture-scenario-flow.mjs index c687df0e2..a9a3b2935 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-scenario-flow.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-scenario-flow.mjs @@ -41,6 +41,9 @@ import { TERMINAL_CANCELED_AFTER_ANSWER_SCENARIO, TERMINAL_FAILED_AFTER_ANSWER_SCENARIO, TERMINAL_STALE_GUARD_SCENARIO, + TYPED_ERROR_RETRY_FAILURE_SCENARIO, + TYPED_ERROR_RETRY_SUCCESS_SCENARIO, + TURN_PLAN_UPDATE_SCENARIO, } from "./claw-chat-current-fixture-constants.mjs"; import { runApprovalRequestDecisionScenario, @@ -84,6 +87,8 @@ import { runTerminalFailedAfterAnswerScenario, } from "./claw-chat-current-fixture-terminal-after-answer.mjs"; 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 { createExpertSkillsRuntimeSession, openSessionFromSidebar, @@ -522,6 +527,18 @@ export async function executeScenarioFlow({ threadId: summary.threadId, }), ); + } else if (options.scenario === TURN_PLAN_UPDATE_SCENARIO) { + Object.assign( + summary, + await runTurnPlanUpdateScenario({ + page, + options, + summary, + appServerRequests, + readTextProviderRequests, + logStage, + }), + ); } else if (options.scenario === "goal") { logStage("enable-goal-mode-from-gui"); summary.goalModeEnabled = sanitizeJson( @@ -686,6 +703,20 @@ export async function executeScenarioFlow({ logStage, }), ); + } else if ( + options.scenario === TYPED_ERROR_RETRY_SUCCESS_SCENARIO || + options.scenario === TYPED_ERROR_RETRY_FAILURE_SCENARIO + ) { + Object.assign( + summary, + await runTypedErrorScenario({ + page, + options, + appServerRequests, + runtimeEnv, + logStage, + }), + ); } else if (options.scenario === "web-tools-rendering") { Object.assign( summary, @@ -867,6 +898,9 @@ export async function executeScenarioFlow({ options.scenario !== TERMINAL_FAILED_AFTER_ANSWER_SCENARIO && options.scenario !== TERMINAL_CANCELED_AFTER_ANSWER_SCENARIO && options.scenario !== TERMINAL_STALE_GUARD_SCENARIO && + options.scenario !== TYPED_ERROR_RETRY_SUCCESS_SCENARIO && + options.scenario !== TYPED_ERROR_RETRY_FAILURE_SCENARIO && + options.scenario !== TURN_PLAN_UPDATE_SCENARIO && options.scenario !== APPROVAL_REQUEST_RESUME_SCENARIO && options.scenario !== APPROVAL_REQUEST_DECLINE_SCENARIO && options.scenario !== APPROVAL_REQUEST_CANCEL_SCENARIO && diff --git a/scripts/agent-runtime/claw-chat-current-fixture-skills-runtime-assertions.mjs b/scripts/agent-runtime/claw-chat-current-fixture-skills-runtime-assertions.mjs index b46ce47d0..684a60558 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-skills-runtime-assertions.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-skills-runtime-assertions.mjs @@ -25,6 +25,31 @@ export function buildSkillsRuntimeScenarioAssertions({ workspace, }) { return { + initialCurrentSkillListObserved: + summary.skillsChangedCatalogRefresh?.initialCatalog?.afterPanelOpen + ?.method === "skill/list" && + summary.skillsChangedCatalogRefresh?.initialCatalog?.afterPanelOpen + ?.electronIpcSuccessCount > + summary.skillsChangedCatalogRefresh?.initialCatalog?.beforeOpen + ?.electronIpcSuccessCount, + typedSkillsChangedConsumed: + summary.skillsChangedCatalogRefresh?.notification?.method === + "skills/changed" && + summary.skillsChangedCatalogRefresh?.notification?.marker === + "skillsChanged.received" && + summary.skillsChangedCatalogRefresh?.notification?.markerCount > 0, + automaticSkillListRefreshObserved: + summary.skillsChangedCatalogRefresh?.automaticRefresh?.method === + "skill/list" && + summary.skillsChangedCatalogRefresh?.automaticRefresh?.transport === + "electron-ipc" && + summary.skillsChangedCatalogRefresh?.automaticRefresh?.increment > 0, + guiSkillCatalogUpdated: + summary.skillsChangedCatalogRefresh?.gui?.panelVisible === true && + summary.skillsChangedCatalogRefresh?.gui?.selectorVisible === true && + summary.skillsChangedCatalogRefresh?.gui?.skillVisible === true, + skillsCatalogRefreshDidNotUseManualRefresh: + summary.skillsChangedCatalogRefresh?.manualRefresh?.clickCount === 0, skillsRuntimePromptReachedBackend: skillsRuntimeTurnStart?.inputText === SKILLS_RUNTIME_PROMPT, guiSkillsRuntimeInputSubmitted: @@ -80,8 +105,8 @@ export function buildSkillsRuntimeScenarioAssertions({ readModelExplicitSkillsRuntimeCompleted: summary.readModelExplicitSkillsRuntimeCompleted?.includesPrompt === true && - (summary.readModelExplicitSkillsRuntimeCompleted?.includesAssistantDone === - true || + (summary.readModelExplicitSkillsRuntimeCompleted + ?.includesAssistantDone === true || summary.readModelExplicitSkillsRuntimeCompleted ?.includesAssistantSummary === true), readModelExplicitSkillSearchObserved: @@ -128,8 +153,8 @@ export function buildSkillsRuntimeScenarioAssertions({ summary.manualEnableSkillsRuntimeTurnStart?.launch?.clicked === true && summary.manualEnableSkillsRuntimeTurnStart?.launch ?.registeredPanelVisible === true && - summary.manualEnableSkillsRuntimeTurnStart?.launch?.enableButtonVisible === - true && + summary.manualEnableSkillsRuntimeTurnStart?.launch + ?.enableButtonVisible === true && summary.manualEnableSkillsRuntimeTurnStart?.launch ?.enableButtonDisabled === false, manualEnableSkillsRuntimeUsedAgentSession: @@ -357,8 +382,8 @@ export function buildExpertSkillsRuntimeScenarioAssertions({ expertPanelEvidenceSkillInvocationObserved: summary.evidencePackExpertPanelSkillsRuntime ?.hasSkillInvocationSummary === true && - summary.evidencePackExpertPanelSkillsRuntime?.invocationSkillName === - SKILLS_RUNTIME_SKILL_NAME, + summary.evidencePackExpertPanelSkillsRuntime + ?.invocationSkillName === SKILLS_RUNTIME_SKILL_NAME, expertPanelSkillSearchBeforeSkillInvocation: summary.evidencePackExpertPanelSkillsRuntime ?.skillSearchBeforeSkillInvocation === true, diff --git a/scripts/agent-runtime/claw-chat-current-fixture-skills-runtime-flow.mjs b/scripts/agent-runtime/claw-chat-current-fixture-skills-runtime-flow.mjs index f7c9702ff..751b2d62c 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-skills-runtime-flow.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-skills-runtime-flow.mjs @@ -31,6 +31,7 @@ import { ensureManualEnableWorkspaceSkill, ensureUserVisibleCapabilityReportSkill, launchSkillsRuntimeFromWorkspacePanel, + verifySkillsChangedCatalogRefresh, waitForExpertPanelSkillsRuntimeSessionReady, } from "./claw-chat-current-fixture-skills-workspace.mjs"; import { @@ -58,6 +59,13 @@ export async function runSkillsRuntimeScenario({ }) { const result = {}; + logStage("verify-skills-changed-catalog-refresh"); + result.skillsChangedCatalogRefresh = await verifySkillsChangedCatalogRefresh( + page, + options, + runtimeEnv, + ); + logStage("send-skills-runtime-prompt-from-gui"); result.skillsRuntimeInputSend = sanitizeJson( await sendPromptFromGui(page, options, SKILLS_RUNTIME_PROMPT), diff --git a/scripts/agent-runtime/claw-chat-current-fixture-skills-workspace.mjs b/scripts/agent-runtime/claw-chat-current-fixture-skills-workspace.mjs index cbea00abb..ffa8355d1 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-skills-workspace.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-skills-workspace.mjs @@ -1,6 +1,11 @@ import fs from "node:fs"; import path from "node:path"; -import { evaluatePageSnapshot } from "./claw-chat-current-fixture-rpc.mjs"; +import { APP_SERVER_HANDLE_JSON_LINES_COMMAND } from "./claw-chat-current-fixture-constants.mjs"; +import { + decodeJsonRpcLines, + evaluatePageSnapshot, + readTraceMessages, +} from "./claw-chat-current-fixture-rpc.mjs"; import { assert, sanitizeJson, @@ -8,6 +13,236 @@ import { writeJsonFile, } from "./claw-chat-current-fixture-utils.mjs"; +const SKILLS_CHANGED_FIXTURE_SKILL_NAME = "notification-refresh"; +const SKILL_LIST_METHOD = "skill/list"; +const SKILLS_CHANGED_DEBUG_MARKER = "skillsChanged.received"; + +async function readSkillListTrace(page) { + const traceRaw = await page.evaluate(() => + window.localStorage.getItem("lime_invoke_trace_buffer_v1"), + ); + const matchingEntries = readTraceMessages(traceRaw).filter((entry) => { + if (entry?.command !== APP_SERVER_HANDLE_JSON_LINES_COMMAND) { + return false; + } + return decodeJsonRpcLines(entry?.args_preview?.request?.lines).some( + (message) => message?.method === SKILL_LIST_METHOD, + ); + }); + return { + method: SKILL_LIST_METHOD, + totalCount: matchingEntries.length, + electronIpcSuccessCount: matchingEntries.filter( + (entry) => + entry?.transport === "electron-ipc" && entry?.status === "success", + ).length, + }; +} + +async function readSkillSelectorSnapshot(page) { + return await evaluatePageSnapshot( + page, + (skillName) => { + const panel = document.querySelector( + '[data-testid="inputbar-plus-panel-skills"]', + ); + const selector = document.querySelector( + '[data-testid="skill-selector-inline"]', + ); + const refreshClicks = Number( + document.documentElement.dataset.skillsRuntimeRefreshClicks || "0", + ); + return { + panelVisible: + panel instanceof HTMLElement && panel.offsetParent !== null, + selectorVisible: + selector instanceof HTMLElement && selector.offsetParent !== null, + skillVisible: Boolean( + selector?.textContent?.includes(String(skillName || "")), + ), + manualRefreshClickCount: Number.isFinite(refreshClicks) + ? refreshClicks + : null, + }; + }, + SKILLS_CHANGED_FIXTURE_SKILL_NAME, + ); +} + +async function installManualRefreshClickTracker(page) { + await page.evaluate(() => { + const runtimeWindow = window; + runtimeWindow.__limeSkillsRuntimeRefreshTrackerCleanup?.(); + document.documentElement.dataset.skillsRuntimeRefreshClicks = "0"; + const onClick = (event) => { + const target = + event.target instanceof Element + ? event.target.closest('[data-testid="skill-selector-refresh"]') + : null; + if (!target) { + return; + } + const count = Number( + document.documentElement.dataset.skillsRuntimeRefreshClicks || "0", + ); + document.documentElement.dataset.skillsRuntimeRefreshClicks = String( + Number.isFinite(count) ? count + 1 : 1, + ); + }; + document.addEventListener("click", onClick, true); + runtimeWindow.__limeSkillsRuntimeRefreshTrackerCleanup = () => { + document.removeEventListener("click", onClick, true); + delete runtimeWindow.__limeSkillsRuntimeRefreshTrackerCleanup; + }; + }); +} + +async function removeManualRefreshClickTracker(page) { + await page + .evaluate(() => { + window.__limeSkillsRuntimeRefreshTrackerCleanup?.(); + }) + .catch(() => undefined); +} + +function writeSkillsChangedFixtureSkill(runtimeEnv) { + const home = runtimeEnv?.env?.HOME; + assert(home, "Skills changed fixture 缺少临时 HOME"); + const skillDirectory = path.join( + home, + ".agents", + "skills", + SKILLS_CHANGED_FIXTURE_SKILL_NAME, + ); + fs.mkdirSync(skillDirectory, { recursive: true }); + fs.writeFileSync( + path.join(skillDirectory, "SKILL.md"), + [ + "---", + `name: ${SKILLS_CHANGED_FIXTURE_SKILL_NAME}`, + "description: Proves typed skills changed catalog invalidation.", + "---", + "", + `# ${SKILLS_CHANGED_FIXTURE_SKILL_NAME}`, + "", + "Use only for the controlled Electron catalog refresh fixture.", + "", + ].join("\n"), + ); + return { skillName: SKILLS_CHANGED_FIXTURE_SKILL_NAME }; +} + +export async function verifySkillsChangedCatalogRefresh( + page, + options, + runtimeEnv, +) { + const beforeOpenTrace = await readSkillListTrace(page); + await page.locator('[data-testid="inputbar-plus-trigger"]').click(); + await page.locator('[data-testid="inputbar-plus-skills"]').click(); + await page + .locator('[data-testid="inputbar-plus-panel-skills"]') + .waitFor({ state: "visible", timeout: options.timeoutMs }); + await page + .locator('[data-testid="skill-selector-inline"]') + .waitFor({ state: "visible", timeout: options.timeoutMs }); + + const initialStartedAt = Date.now(); + let initialTrace = beforeOpenTrace; + let initialGui = null; + while (Date.now() - initialStartedAt < options.timeoutMs) { + initialTrace = await readSkillListTrace(page); + initialGui = await readSkillSelectorSnapshot(page); + if ( + initialGui?.panelVisible && + initialGui?.selectorVisible && + initialTrace.electronIpcSuccessCount > + beforeOpenTrace.electronIpcSuccessCount + ) { + break; + } + await sleep(options.intervalMs); + } + assert( + initialTrace.electronIpcSuccessCount > + beforeOpenTrace.electronIpcSuccessCount, + `技能面板未触发初始 current skill/list: ${JSON.stringify( + sanitizeJson({ beforeOpenTrace, initialTrace, initialGui }), + )}`, + ); + + await installManualRefreshClickTracker(page); + const notificationMarkers = []; + const collectNotificationMarker = (message) => { + const text = String(message.text?.() || ""); + if (text.includes(SKILLS_CHANGED_DEBUG_MARKER)) { + notificationMarkers.push(SKILLS_CHANGED_DEBUG_MARKER); + } + }; + page.on("console", collectNotificationMarker); + + let completed = null; + let lastSnapshot = null; + try { + const fixtureSkill = writeSkillsChangedFixtureSkill(runtimeEnv); + const startedAt = Date.now(); + while (Date.now() - startedAt < options.timeoutMs) { + const trace = await readSkillListTrace(page); + const gui = await readSkillSelectorSnapshot(page); + lastSnapshot = { trace, gui, markerCount: notificationMarkers.length }; + if ( + notificationMarkers.length > 0 && + trace.electronIpcSuccessCount > initialTrace.electronIpcSuccessCount && + gui?.panelVisible && + gui?.selectorVisible && + gui?.skillVisible && + gui?.manualRefreshClickCount === 0 + ) { + completed = { + skillName: fixtureSkill.skillName, + initialCatalog: { + beforeOpen: beforeOpenTrace, + afterPanelOpen: initialTrace, + gui: initialGui, + }, + notification: { + method: "skills/changed", + marker: SKILLS_CHANGED_DEBUG_MARKER, + markerCount: notificationMarkers.length, + }, + automaticRefresh: { + beforeCount: initialTrace.electronIpcSuccessCount, + afterCount: trace.electronIpcSuccessCount, + increment: + trace.electronIpcSuccessCount - + initialTrace.electronIpcSuccessCount, + method: SKILL_LIST_METHOD, + transport: "electron-ipc", + }, + gui, + manualRefresh: { + clickCount: gui.manualRefreshClickCount, + }, + }; + break; + } + await sleep(options.intervalMs); + } + } finally { + page.off("console", collectNotificationMarker); + await removeManualRefreshClickTracker(page); + } + + assert( + completed, + `skills/changed 未自动刷新 GUI catalog: ${JSON.stringify( + sanitizeJson(lastSnapshot), + )}`, + ); + await page.keyboard.press("Escape").catch(() => undefined); + return sanitizeJson(completed); +} + export async function waitForExpertPanelSkillsRuntimeSessionReady( page, options, diff --git a/scripts/agent-runtime/claw-chat-current-fixture-smoke-skills-runtime-guards.mjs b/scripts/agent-runtime/claw-chat-current-fixture-smoke-skills-runtime-guards.mjs index 964898c36..0ffb5a2ed 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-smoke-skills-runtime-guards.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-smoke-skills-runtime-guards.mjs @@ -160,6 +160,22 @@ export function registerSkillsRuntimeSmokeGuards({ '"evidence/export"', "includeEvidencePack: true", "waitForGuiSkillsRuntimeCompleted", + "verifySkillsChangedCatalogRefresh", + "verify-skills-changed-catalog-refresh", + "inputbar-plus-trigger", + "inputbar-plus-skills", + "inputbar-plus-panel-skills", + "skill-selector-inline", + "skill-selector-refresh", + '"skill/list"', + '"skills/changed"', + '"skillsChanged.received"', + "initialCurrentSkillListObserved", + "typedSkillsChangedConsumed", + "automaticSkillListRefreshObserved", + "guiSkillCatalogUpdated", + "skillsCatalogRefreshDidNotUseManualRefresh", + 'transport: "electron-ipc"', "scenario.guiSummaryText ?? scenario.summaryText", "waitForSessionReadSkillsRuntimeCompleted", "summarizeSkillsRuntimeReadModel", diff --git a/scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs b/scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs index 5bbe470cb..b65eaed06 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs @@ -41,6 +41,12 @@ import { TERMINAL_CANCELED_AFTER_ANSWER_SCENARIO, TERMINAL_FAILED_AFTER_ANSWER_SCENARIO, TERMINAL_STALE_GUARD_SCENARIO, + TYPED_ERROR_RETRY_FAILURE_PROMPT, + TYPED_ERROR_RETRY_FAILURE_SCENARIO, + TYPED_ERROR_RETRY_SUCCESS_PROMPT, + TYPED_ERROR_RETRY_SUCCESS_SCENARIO, + TURN_PLAN_UPDATE_PROMPT, + TURN_PLAN_UPDATE_SCENARIO, } from "./claw-chat-current-fixture-constants.mjs"; import { buildFixtureAssertionReport } from "./claw-chat-current-fixture-assertions.mjs"; import { resolveGateBExpectedIdentity } from "./claw-chat-current-fixture-assertion-context.mjs"; @@ -120,8 +126,8 @@ Claw Chat Current Electron Fixture Smoke 的主链可以完成用户消息、assistant 输出和 read model 收尾。 边界: - 普通聊天场景使用一次性本地 external backend fixture;图片命令场景使用 - APP_SERVER_BACKEND_MODE=runtime 以验证 App Server ImageCommandWorkflow。 + 普通聊天场景使用一次性本地 external backend fixture;图片命令、Soul、 + active steer 与 turn-plan-update 场景使用 APP_SERVER_BACKEND_MODE=runtime。 全部场景均不调用正式模型后端,不使用 APP_SERVER_BACKEND_MODE=mock, 不走 Tauri / legacy runtime command / renderer mock fallback 作为成功证据。 @@ -133,7 +139,7 @@ Claw Chat Current Electron Fixture Smoke --evidence-dir 证据目录 --prefix 证据文件前缀 --run-id Gate 项目 run-id;也可通过 LIME_GATE_RUN_ID 注入 - --scenario complete | home-hotpath | home-hotpath-greeting | cancel | cancel-then-continue | inputbar-rich-restore | inputbar-active-steer | plan | 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 | 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 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 --prompt 仅 home-hotpath 场景可用,覆盖默认新闻输入 --soul-style-profile soul-style 场景使用的 profile,默认 ${DEFAULT_SOUL_STYLE_FIXTURE_PROFILE_ID} --cdp-port 可选 Electron remote debugging port;传入后通过 CDP renderer 执行 GUI 动作 @@ -252,6 +258,7 @@ function parseArgs(argv) { INPUTBAR_RICH_RESTORE_SCENARIO, ACTIVE_STEER_SCENARIO, "plan", + TURN_PLAN_UPDATE_SCENARIO, "goal", SOUL_STYLE_SCENARIO, IMAGE_COMMAND_SCENARIO, @@ -268,6 +275,8 @@ function parseArgs(argv) { TERMINAL_CANCELED_AFTER_ANSWER_SCENARIO, TERMINAL_FAILED_AFTER_ANSWER_SCENARIO, TERMINAL_STALE_GUARD_SCENARIO, + TYPED_ERROR_RETRY_SUCCESS_SCENARIO, + TYPED_ERROR_RETRY_FAILURE_SCENARIO, "web-tools-rendering", "mcp-structured-content", "skills-runtime", @@ -318,6 +327,7 @@ function shouldUseTextProviderFixture(scenario) { scenario === SOUL_STYLE_SCENARIO || scenario === INPUTBAR_RICH_RESTORE_SCENARIO || scenario === ACTIVE_STEER_SCENARIO || + scenario === TURN_PLAN_UPDATE_SCENARIO || scenario === CONTENT_FACTORY_ARTICLE_WORKSPACE_SCENARIO || scenario === CONTENT_FACTORY_INLINE_IMAGE_ARTICLE_WORKSPACE_SCENARIO ); @@ -329,7 +339,9 @@ function scenarioWaitsForExternalBackendCancel(scenario) { scenario === "cancel-then-continue" || scenario === INPUTBAR_RICH_RESTORE_SCENARIO || scenario === TERMINAL_CANCELED_AFTER_ANSWER_SCENARIO || - scenario === APPROVAL_REQUEST_HOST_INTERRUPT_SCENARIO + scenario === APPROVAL_REQUEST_HOST_INTERRUPT_SCENARIO || + scenario === TYPED_ERROR_RETRY_SUCCESS_SCENARIO || + scenario === TYPED_ERROR_RETRY_FAILURE_SCENARIO ); } @@ -345,6 +357,7 @@ function resolveScenarioBackendEnv(options, runtimeEnv) { if ( options.scenario === SOUL_STYLE_SCENARIO || options.scenario === ACTIVE_STEER_SCENARIO || + options.scenario === TURN_PLAN_UPDATE_SCENARIO || options.scenario === CONTENT_FACTORY_ARTICLE_WORKSPACE_SCENARIO || options.scenario === CONTENT_FACTORY_INLINE_IMAGE_ARTICLE_WORKSPACE_SCENARIO ) { @@ -369,6 +382,7 @@ function resolveScenarioBackendEnv(options, runtimeEnv) { runtimeEnv.backendPath, runtimeEnv.backendLedgerPath, runtimeEnv.cancelSignalPath, + runtimeEnv.typedErrorSignalPath, ]), APP_SERVER_BACKEND_TIMEOUT_MS: backendTimeoutMs, }; @@ -543,9 +557,17 @@ async function run() { ? APPROVAL_REQUEST_RESUME_PROMPT : options.scenario === APPROVAL_REQUEST_FULL_ACCESS_SCENARIO ? APPROVAL_REQUEST_FULL_ACCESS_PROMPT - : options.scenario === HOME_HOTPATH_GREETING_SCENARIO - ? GREETING_PROMPT - : NEWS_PROMPT), + : options.scenario === TYPED_ERROR_RETRY_SUCCESS_SCENARIO + ? TYPED_ERROR_RETRY_SUCCESS_PROMPT + : options.scenario === + TYPED_ERROR_RETRY_FAILURE_SCENARIO + ? TYPED_ERROR_RETRY_FAILURE_PROMPT + : options.scenario === TURN_PLAN_UPDATE_SCENARIO + ? TURN_PLAN_UPDATE_PROMPT + : options.scenario === + HOME_HOTPATH_GREETING_SCENARIO + ? GREETING_PROMPT + : NEWS_PROMPT), sessionId: null, threadId: null, workspaceId: null, @@ -629,6 +651,7 @@ async function run() { guiWebToolsRenderingCompleted: null, skillsRuntimeInputSend: null, guiSkillsRuntimeCompleted: null, + skillsChangedCatalogRefresh: null, explicitSkillsRuntimeInputSend: null, guiExplicitSkillsRuntimeCompleted: null, manualEnableSkillsRuntimeTurnStart: null, @@ -640,6 +663,16 @@ async function run() { readModelPlanCompleted: null, readModelGoalCompleted: null, readModelReasoningFirstVisibleCompleted: null, + typedErrorTurnStart: null, + typedErrorRetryInputSend: null, + typedErrorRetryingBackend: null, + typedErrorRetryingGui: null, + typedErrorAwaitingTerminalBackend: null, + typedErrorAwaitingTerminalGui: null, + readModelTypedErrorPending: null, + guiTypedErrorTerminal: null, + readModelTypedErrorTerminal: null, + typedErrorTerminalBackend: null, readModelWebToolsRenderingCompleted: null, imageCommandInputSend: null, imageCommandBackendTurnStart: null, @@ -754,6 +787,7 @@ async function run() { soulStyleExpectation: soulStyleSelection, activeSteerDelayMs: options.scenario === ACTIVE_STEER_SCENARIO ? 30_000 : 0, + turnPlanUpdate: options.scenario === TURN_PLAN_UPDATE_SCENARIO, }); summary.textProviderFixtureServer = sanitizeJson({ baseUrl: textProviderFixtureServer.baseUrl, diff --git a/scripts/agent-runtime/claw-chat-current-fixture-smoke.test.mjs b/scripts/agent-runtime/claw-chat-current-fixture-smoke.test.mjs index b07d0966a..75f7833db 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-smoke.test.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-smoke.test.mjs @@ -8,6 +8,9 @@ import { HOME_HOTPATH_SCENARIO, IMAGE_FIXTURE_MODEL, TEXT_PROVIDER_FIXTURE_API_KEY, + TURN_PLAN_UPDATE_DONE_TEXT, + TURN_PLAN_UPDATE_PROMPT, + TURN_PLAN_UPDATE_STEPS, } from "./claw-chat-current-fixture-constants.mjs"; import { readTraceTurnStartInputText, @@ -49,6 +52,7 @@ import { summarizeHomeHotpathPreTurnTrace, } from "./claw-chat-current-fixture-home-hotpath.mjs"; import { runtimeEventFromDirectNotification } from "./claw-chat-current-fixture-rpc.mjs"; +import { readModelLatestTurnStatus } from "./claw-chat-current-fixture-read-model-core.mjs"; import { isGuiChatCompletedSnapshotReady } from "./claw-chat-current-fixture-gui-completion-waits.mjs"; import { IMAGE_PROVIDER_FIXTURE_API_KEY, @@ -83,6 +87,7 @@ const fixtureSourceFiles = [ "scripts/agent-runtime/claw-chat-current-fixture-gui-input-modes.mjs", "scripts/agent-runtime/claw-chat-current-fixture-gui-tool-waits.mjs", "scripts/agent-runtime/claw-chat-current-fixture-gui-web-tools-waits.mjs", + "scripts/agent-runtime/claw-chat-current-fixture-turn-plan-update.mjs", "scripts/agent-runtime/claw-chat-current-fixture-approval-resume.mjs", "scripts/agent-runtime/claw-chat-current-fixture-approval-gui.mjs", "scripts/agent-runtime/claw-chat-current-fixture-approval-read-model.mjs", @@ -98,6 +103,7 @@ const fixtureSourceFiles = [ "scripts/agent-runtime/claw-chat-current-fixture-skills-runtime-flow.mjs", "scripts/agent-runtime/claw-chat-current-fixture-terminal-after-answer.mjs", "scripts/agent-runtime/claw-chat-current-fixture-terminal-stale-guard.mjs", + "scripts/agent-runtime/claw-chat-current-fixture-typed-error.mjs", "scripts/agent-runtime/claw-chat-current-fixture-live-tail.mjs", "scripts/agent-runtime/claw-chat-current-fixture-resize-reflow.mjs", "scripts/agent-runtime/claw-chat-current-fixture-web-tools-rendering.mjs", @@ -174,6 +180,17 @@ describe("Claw GUI canonical completion guard", () => { }); }); +describe("Claw read model status guard", () => { + it("reads terminal status from canonical top-level turns", () => { + expect( + readModelLatestTurnStatus({ + turns: [{ id: "turn-typed-error", status: "completed" }], + detail: { status: "running" }, + }), + ).toBe("completed"); + }); +}); + function readSmokeScript() { return fixtureSourceFiles .map((filePath) => fs.readFileSync(filePath, "utf8")) @@ -1096,6 +1113,21 @@ describe("claw chat current Electron fixture smoke guard", () => { turnId: "turn-1", type: "item.completed", }); + expect( + runtimeEventFromDirectNotification({ + method: "turn/plan/updated", + params: { + threadId: "thread-1", + turnId: "turn-1", + explanation: "继续执行", + plan: [{ step: "补主链", status: "inProgress" }], + }, + }), + ).toMatchObject({ + threadId: "thread-1", + turnId: "turn-1", + type: "turn.plan.updated", + }); expect( runtimeEventFromDirectNotification({ method: "turn/completed", @@ -1400,6 +1432,11 @@ describe("claw chat current Electron fixture smoke guard", () => { expect(content).toContain("attachFixtureImage"); expect(content).toContain("dropPathReference"); expect(content).toContain("selectCapabilityReportSkill"); + expect(content).toContain("normalizeMentionText"); + expect(content).toContain("toLocaleLowerCase()"); + expect(content.split('.replace(/[\\s_-]+/g, "")')).toHaveLength(3); + expect(content).toContain("expectedSkillName"); + expect(content).toContain("lastSelectionResult"); expect(content).toContain("sendPromptFromGui"); expect(content).toContain("waitForBackendLedgerTurnStart"); expect(content).toContain("waitForStopButtonVisibleAndClick"); @@ -2043,6 +2080,88 @@ describe("claw chat current Electron fixture smoke guard", () => { } }); + it("returns update_plan first and final text only after the tool result", async () => { + const fixture = await startTextProviderFixtureServer({ + turnPlanUpdate: true, + }); + const headers = { + Authorization: `Bearer ${TEXT_PROVIDER_FIXTURE_API_KEY}`, + "content-type": "application/json", + }; + const tools = [ + { + type: "function", + function: { name: "update_plan", parameters: { type: "object" } }, + }, + ]; + try { + const firstResponse = await fetch(`${fixture.baseUrl}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + stream: true, + tools, + messages: [{ role: "user", content: TURN_PLAN_UPDATE_PROMPT }], + }), + }); + const firstBody = await firstResponse.text(); + expect(firstBody).toContain("update_plan"); + for (const step of TURN_PLAN_UPDATE_STEPS) { + expect(firstBody).toContain(step.step); + } + expect(firstBody).not.toContain(TURN_PLAN_UPDATE_DONE_TEXT); + + const secondResponse = await fetch( + `${fixture.baseUrl}/chat/completions`, + { + method: "POST", + headers, + body: JSON.stringify({ + stream: true, + tools, + messages: [ + { role: "user", content: TURN_PLAN_UPDATE_PROMPT }, + { + role: "assistant", + tool_calls: [ + { + id: "call_turn_plan_update", + type: "function", + function: { name: "update_plan", arguments: "{}" }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_turn_plan_update", + content: "Plan updated", + }, + ], + }), + }, + ); + expect(await secondResponse.text()).toContain(TURN_PLAN_UPDATE_DONE_TEXT); + + const requests = fixture + .requests() + .filter((request) => request.method === "POST"); + expect(requests).toHaveLength(2); + expect(requests[0].bodySummary).toMatchObject({ + bodyIncludesTurnPlanUpdatePrompt: true, + bodyIncludesUpdatePlanToolDefinition: true, + bodyIncludesPlanUpdatedToolResult: false, + }); + expect(requests[1].bodySummary).toMatchObject({ + bodyIncludesTurnPlanUpdatePrompt: true, + bodyIncludesUpdatePlanToolDefinition: true, + bodyIncludesPlanUpdatedToolResult: true, + }); + expect(requests.every((request) => !("body" in request))).toBe(true); + } finally { + await fixture.close(); + } + }); + it("requires scoped credential for text provider model discovery", async () => { const fixture = await startTextProviderFixtureServer(); try { @@ -2494,6 +2613,52 @@ describe("claw chat current Electron fixture smoke guard", () => { expect(content).not.toContain("agent_runtime_"); }); + it("covers typed error retry success and failure in real Electron fixtures", () => { + const content = readSmokeScript(); + const regressionContent = readCurrentFixtureRegressionSmokeScript(); + const typedErrorBranchStart = content.indexOf( + "if (isTypedErrorRetrySuccessPrompt || isTypedErrorRetryFailurePrompt)", + ); + const typedErrorBranch = content.slice( + typedErrorBranchStart, + content.indexOf( + "if (isImageTaskPresentationPrompt)", + typedErrorBranchStart, + ), + ); + + expect(content).toContain("typed-error-retry-success"); + expect(content).toContain("typed-error-retry-failure"); + expect(content).toContain("plugin_worker.retry"); + expect(content).toContain('type: "runtime.error"'); + expect(content).toContain("willRetry: false"); + expect(content).toContain('type: "turn.failed"'); + expect(content).toContain('type: "turn.completed"'); + expect(content).toContain("wait-typed-error-retrying-gui"); + expect(content).toContain("wait-typed-error-awaiting-terminal-gui"); + expect(content).toContain("hasExpectedPhase"); + expect(content).toContain('phase: "retrying"'); + expect(content).toContain('phase: "failed"'); + expect(content).toContain("typedErrorSignalPath"); + expect(content).toContain("readModelTypedErrorPending"); + expect(content).toContain("TYPED_ERROR_RETRY_ASSERTION_KEYS"); + expect(content).toContain("typedErrorRetryFailureNoPrematureTerminal"); + expect(content).toContain("typedErrorRetryIdentityConsistent"); + expect( + typedErrorBranch.match(/type: "provider\.first_text_delta\.received"/g), + ).toHaveLength(2); + expect(regressionContent).toContain( + "Claw typed error retry success Electron fixture", + ); + expect(regressionContent).toContain( + "Claw typed error retry failure Electron fixture", + ); + expect(regressionContent).toContain( + "agentStreamRuntimeHandler.typedError.test.ts", + ); + expect(content).not.toContain("agent_runtime_"); + }); + it("covers MCP structuredContent rendering in the real Electron fixture", () => { const content = readSmokeScript(); diff --git a/scripts/agent-runtime/claw-chat-current-fixture-turn-plan-update.mjs b/scripts/agent-runtime/claw-chat-current-fixture-turn-plan-update.mjs new file mode 100644 index 000000000..55d957288 --- /dev/null +++ b/scripts/agent-runtime/claw-chat-current-fixture-turn-plan-update.mjs @@ -0,0 +1,445 @@ +import { + APP_SERVER_METHOD_SESSION_LIST, + APP_SERVER_METHOD_SESSION_READ, + APP_SERVER_METHOD_SESSION_START, + APP_SERVER_METHOD_SESSION_TURN_START, + SESSION_TITLE, + TURN_PLAN_UPDATE_DONE_TEXT, + TURN_PLAN_UPDATE_EXPLANATION, + TURN_PLAN_UPDATE_PROMPT, + TURN_PLAN_UPDATE_SCENARIO, + TURN_PLAN_UPDATE_STEPS, +} from "./claw-chat-current-fixture-constants.mjs"; +import { sendPromptFromGui } from "./claw-chat-current-fixture-gui-actions.mjs"; +import { waitForGuiChatCompleted } from "./claw-chat-current-fixture-gui-completion-waits.mjs"; +import { + collectReadModelItems, + collectReadModelTurns, + readModelLatestTurnStatus, + readModelTurnId, +} from "./claw-chat-current-fixture-read-model-core.mjs"; +import { waitForSessionReadCompleted } from "./claw-chat-current-fixture-read-model-waits.mjs"; +import { + collectRuntimeEvents, + drainAppServerEventsFromPage, + evaluatePageSnapshot, + invokeAppServerFromPage, + reloadRendererDocument, + waitForRendererReady, +} from "./claw-chat-current-fixture-rpc.mjs"; +import { + openSessionFromSidebar, + waitForGuiSessionVisible, +} from "./claw-chat-current-fixture-session.mjs"; +import { sanitizeJson, sleep } from "./claw-chat-current-fixture-utils.mjs"; + +export async function runTurnPlanUpdateScenario({ + page, + options, + summary, + appServerRequests, + readTextProviderRequests, + logStage, +}) { + logStage("send-turn-plan-update-prompt-from-gui"); + const turnPlanUpdateInputSend = await sendPromptFromGui( + page, + options, + TURN_PLAN_UPDATE_PROMPT, + ); + summary.turnPlanUpdateInputSend = sanitizeJson(turnPlanUpdateInputSend); + + logStage("wait-gui-turn-plan-update-completed"); + const guiTurnPlanUpdateCompleted = await waitForGuiChatCompleted( + page, + options, + { + prompt: TURN_PLAN_UPDATE_PROMPT, + doneText: TURN_PLAN_UPDATE_DONE_TEXT, + summaryText: "执行清单已更新", + }, + ); + summary.guiTurnPlanUpdateCompleted = sanitizeJson(guiTurnPlanUpdateCompleted); + + logStage("wait-read-model-turn-plan-update-completed"); + const readModel = await waitForSessionReadCompleted( + page, + options, + appServerRequests, + { + prompt: TURN_PLAN_UPDATE_PROMPT, + doneText: TURN_PLAN_UPDATE_DONE_TEXT, + summaryText: "执行清单已更新", + }, + ); + const readModelTurnPlanUpdateCompleted = + summarizeTurnPlanReadModel(readModel); + summary.readModelTurnPlanUpdateCompleted = sanitizeJson( + readModelTurnPlanUpdateCompleted, + ); + + logStage("drain-turn-plan-updated-notification"); + const drained = await drainAppServerEventsFromPage(page, 200); + const turnPlanUpdateNotification = summarizeTurnPlanNotification( + drained.messages, + readModelTurnPlanUpdateCompleted.latestTurnId, + ); + summary.turnPlanUpdateDrain = summarizeTurnPlanDrain(drained.messages); + summary.turnPlanUpdateNotification = sanitizeJson(turnPlanUpdateNotification); + + logStage("wait-live-turn-plan-checklist"); + const guiTurnPlanUpdateChecklist = await waitForTurnPlanChecklist( + page, + options, + ); + summary.guiTurnPlanUpdateChecklist = sanitizeJson(guiTurnPlanUpdateChecklist); + + logStage("reload-turn-plan-update-session"); + const turnPlanUpdateReload = await reloadRendererDocument(page, options); + const turnPlanUpdateRendererReady = await waitForRendererReady(page, options); + const turnPlanUpdateSessionVisible = await waitForGuiSessionVisible( + page, + options, + SESSION_TITLE, + ); + const turnPlanUpdateSessionOpened = await openSessionFromSidebar( + page, + options, + appServerRequests, + { + sessionId: options.sessionId, + threadId: options.threadId, + title: SESSION_TITLE, + }, + ); + const guiTurnPlanUpdateHydrated = await waitForTurnPlanChecklist( + page, + options, + ); + const hydratedRead = await invokeAppServerFromPage( + page, + APP_SERVER_METHOD_SESSION_READ, + { threadId: options.threadId, includeTurns: true }, + appServerRequests, + ); + + return sanitizeJson({ + turnPlanUpdateInputSend, + guiTurnPlanUpdateCompleted, + guiTurnPlanUpdateChecklist, + readModelTurnPlanUpdateCompleted, + turnPlanUpdateNotification, + turnPlanUpdateProviderRequests: summarizeProviderRequests( + readTextProviderRequests?.() ?? [], + ), + turnPlanUpdateReload, + turnPlanUpdateRendererReady, + turnPlanUpdateSessionVisible, + turnPlanUpdateSessionOpened, + guiTurnPlanUpdateHydrated, + readModelTurnPlanUpdateHydrated: summarizeTurnPlanReadModel( + hydratedRead.result, + ), + }); +} + +export function buildTurnPlanUpdateScenarioAssertions(context) { + const { summary, traceTurnStarts, appServerRequestMethods } = context; + const live = summary.guiTurnPlanUpdateChecklist; + const hydrated = summary.guiTurnPlanUpdateHydrated; + const readModel = summary.readModelTurnPlanUpdateCompleted; + const hydratedReadModel = summary.readModelTurnPlanUpdateHydrated; + const notification = summary.turnPlanUpdateNotification; + const provider = summary.turnPlanUpdateProviderRequests; + const traceTurnStart = traceTurnStarts.find( + (entry) => + entry.inputText === TURN_PLAN_UPDATE_PROMPT && + entry.transport === "electron-ipc" && + entry.status === "success", + ); + + return { + turnPlanUpdateUsesRuntimeBackend: summary.backendMode === "runtime", + turnPlanUpdateUsesCurrentSessionMethods: + appServerRequestMethods.includes(APP_SERVER_METHOD_SESSION_START) && + appServerRequestMethods.includes(APP_SERVER_METHOD_SESSION_LIST) && + appServerRequestMethods.includes(APP_SERVER_METHOD_SESSION_READ), + turnPlanUpdateUsesCurrentTurnStart: + appServerRequestMethods.includes(APP_SERVER_METHOD_SESSION_TURN_START) && + Boolean(traceTurnStart), + turnPlanUpdateProviderReceivedToolDefinition: + provider.initialRequest?.hasUpdatePlanTool === true, + turnPlanUpdateProviderReturnedAfterToolResult: + provider.followupRequest?.hasPlanUpdatedToolResult === true && + provider.chatRequestCount === 2, + turnPlanUpdateNotificationObserved: + notification.methodCount >= 1 && + notification.normalizedEventType === "turn.plan.updated" && + notification.turnId === readModel.latestTurnId && + notification.threadId === summary.threadId && + notification.explanation === TURN_PLAN_UPDATE_EXPLANATION && + notification.hasExpectedPlan === true, + turnPlanUpdateLiveChecklistVisible: checklistSnapshotPassed(live), + turnPlanUpdateDoesNotCreatePlanUi: + live?.visiblePlanBlockCount === 0 && + live?.visiblePlanDecisionCount === 0 && + live?.visibleUpdatePlanToolRowCount === 0, + turnPlanUpdateReadModelCanonical: + readModel.latestTurnStatus === "completed" && + readModel.updatePlanToolCount === 1 && + readModel.completedUpdatePlanToolCount === 1 && + readModel.planItemCount === 0 && + readModel.includesExpectedArguments === true && + readModel.includesFinalAnswer === true, + turnPlanUpdateHydratesAfterReload: + checklistSnapshotPassed(hydrated) && + hydratedReadModel.latestTurnStatus === "completed" && + hydratedReadModel.updatePlanToolCount === 1 && + hydratedReadModel.planItemCount === 0 && + hydratedReadModel.includesExpectedArguments === true, + turnPlanUpdateInputReadyAfterCompletion: + hydrated?.textareaVisible === true && + hydrated?.textareaDisabled === false && + hydrated?.stopButtonVisible === false, + turnPlanUpdateUserAndAssistantVisible: + summary.guiTurnPlanUpdateCompleted?.hasPrompt === true && + (summary.guiTurnPlanUpdateCompleted?.hasAssistantSummary === true || + summary.guiTurnPlanUpdateCompleted?.hasDoneText === true), + turnPlanUpdateNoInvokeOrConsoleErrors: + !context.errorRaw && context.actionableConsoleErrors.length === 0, + }; +} + +export function summarizeTurnPlanReadModel(readModel) { + const items = dedupeItems(collectReadModelItems(readModel)); + const planItems = items.filter((item) => item?.type === "plan"); + const updatePlanTools = items.filter( + (item) => item?.type === "dynamicToolCall" && item?.tool === "update_plan", + ); + const serializedTools = JSON.stringify(updatePlanTools); + const turns = collectReadModelTurns(readModel); + return sanitizeJson({ + latestTurnId: readModelTurnId(turns.at(-1)), + latestTurnStatus: readModelLatestTurnStatus(readModel), + itemCount: items.length, + planItemCount: planItems.length, + updatePlanToolCount: updatePlanTools.length, + completedUpdatePlanToolCount: updatePlanTools.filter( + (item) => item?.status === "completed" && item?.success === true, + ).length, + includesExpectedArguments: TURN_PLAN_UPDATE_STEPS.every( + (step) => + serializedTools.includes(step.step) && + serializedTools.includes(step.status), + ), + includesPlanUpdatedOutput: serializedTools.includes("Plan updated"), + includesFinalAnswer: JSON.stringify(items).includes( + TURN_PLAN_UPDATE_DONE_TEXT, + ), + }); +} + +function dedupeItems(items) { + const byId = new Map(); + for (const item of items) { + const key = typeof item?.id === "string" ? item.id : JSON.stringify(item); + byId.set(key, item); + } + return [...byId.values()]; +} + +function summarizeProviderRequests(requests) { + const chatRequests = requests.filter( + (request) => + request?.method === "POST" && + ["/chat/completions", "/v1/chat/completions"].includes(request?.url), + ); + const summarize = (request) => ({ + authorized: request?.authorization === "present", + hasPrompt: request?.bodySummary?.bodyIncludesTurnPlanUpdatePrompt === true, + hasUpdatePlanTool: + request?.bodySummary?.bodyIncludesUpdatePlanToolDefinition === true, + hasPlanUpdatedToolResult: + request?.bodySummary?.bodyIncludesPlanUpdatedToolResult === true, + }); + return { + chatRequestCount: chatRequests.length, + initialRequest: summarize(chatRequests[0]), + followupRequest: summarize(chatRequests[1]), + }; +} + +function summarizeTurnPlanNotification(messages, expectedTurnId) { + const matching = messages.filter( + (message) => + message?.method === "turn/plan/updated" && + (!expectedTurnId || message?.params?.turnId === expectedTurnId), + ); + const latest = matching.at(-1); + const normalized = collectRuntimeEvents(matching).at(-1); + const serializedPlan = JSON.stringify(latest?.params?.plan ?? []); + return sanitizeJson({ + methodCount: matching.length, + normalizedEventType: normalized?.type ?? null, + threadId: latest?.params?.threadId ?? null, + turnId: latest?.params?.turnId ?? null, + explanation: latest?.params?.explanation ?? null, + hasExpectedPlan: TURN_PLAN_UPDATE_STEPS.every( + (step) => + serializedPlan.includes(step.step) && + serializedPlan.includes( + step.status === "in_progress" ? "inProgress" : step.status, + ), + ), + }); +} + +function summarizeTurnPlanDrain(messages) { + const methods = messages + .map((message) => message?.method) + .filter((method) => typeof method === "string"); + return sanitizeJson({ + messageCount: messages.length, + methods, + planIndex: methods.lastIndexOf("turn/plan/updated"), + terminalIndex: methods.lastIndexOf("turn/completed"), + }); +} + +async function waitForTurnPlanChecklist(page, options) { + const startedAt = Date.now(); + let lastSnapshot = null; + while (Date.now() - startedAt < options.timeoutMs) { + const snapshot = await evaluatePageSnapshot( + page, + ({ prompt, doneText, steps }) => { + const visible = (node) => { + if (!(node instanceof HTMLElement)) return false; + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + return ( + rect.width > 0 && + rect.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" + ); + }; + const ownerDefinitions = [ + { + kind: "run-control", + root: '[data-testid="task-center-run-control-plan"]', + item: '[data-testid="task-center-run-control-plan-item"]', + revision: '[data-testid="task-center-run-control-plan-revision"]', + }, + { + kind: "task-rail", + root: '[data-testid="task-center-task-rail-plan"]', + item: '[data-testid="task-center-task-rail-plan-item"]', + revision: '[data-testid="task-center-task-rail-plan-revision"]', + }, + ]; + const owners = ownerDefinitions.flatMap((definition) => { + const root = document.querySelector(definition.root); + if (!visible(root)) return []; + const revision = root.querySelector(definition.revision); + return [ + { + kind: definition.kind, + items: Array.from(root.querySelectorAll(definition.item)).map( + (item) => ({ + text: item.textContent || "", + status: item.getAttribute("data-status"), + }), + ), + revisionSource: + revision?.getAttribute("data-plan-source") || null, + }, + ]; + }); + const bodyText = document.body?.innerText || ""; + const expectedStatus = new Map([ + ["completed", "completed"], + ["in_progress", "running"], + ["pending", "pending"], + ]); + const completeOwners = owners.filter((owner) => + steps.every((step) => + owner.items.some( + (item) => + item.text.includes(step.step) && + item.status === expectedStatus.get(step.status), + ), + ), + ); + const textarea = Array.from( + document.querySelectorAll('textarea[name="agent-chat-message"]'), + ).find(visible); + const stopButtons = Array.from( + document.querySelectorAll( + '[data-testid="stop-btn"], [data-testid="stop-button"]', + ), + ).filter(visible); + return { + url: window.location.href, + hasPrompt: bodyText.includes(prompt), + hasDoneText: bodyText.includes(doneText), + ownerCount: owners.length, + completeOwnerCount: completeOwners.length, + owners, + visiblePlanBlockCount: Array.from( + document.querySelectorAll('[data-testid="agent-plan-block"]'), + ).filter(visible).length, + visiblePlanDecisionCount: Array.from( + document.querySelectorAll( + '[data-testid="plan-composer-decision-panel"]', + ), + ).filter(visible).length, + visibleUpdatePlanToolRowCount: Array.from( + document.querySelectorAll( + '[data-testid="tool-call-row"][data-tool-name="update_plan"]', + ), + ).filter(visible).length, + textareaVisible: Boolean(textarea), + textareaDisabled: + textarea instanceof HTMLTextAreaElement ? textarea.disabled : null, + stopButtonVisible: stopButtons.length > 0, + }; + }, + { + prompt: TURN_PLAN_UPDATE_PROMPT, + doneText: TURN_PLAN_UPDATE_DONE_TEXT, + steps: TURN_PLAN_UPDATE_STEPS, + }, + ); + if (!snapshot) { + await sleep(options.intervalMs); + continue; + } + lastSnapshot = snapshot; + if (checklistSnapshotPassed(snapshot)) { + return sanitizeJson(snapshot); + } + await sleep(options.intervalMs); + } + throw new Error( + `GUI 未显示 update_plan checklist: ${JSON.stringify(sanitizeJson(lastSnapshot))}`, + ); +} + +function checklistSnapshotPassed(snapshot) { + return ( + snapshot?.hasPrompt === true && + snapshot?.hasDoneText === true && + snapshot?.completeOwnerCount > 0 && + snapshot?.visiblePlanBlockCount === 0 && + snapshot?.visiblePlanDecisionCount === 0 && + snapshot?.visibleUpdatePlanToolRowCount === 0 + ); +} + +export { + TURN_PLAN_UPDATE_DONE_TEXT, + TURN_PLAN_UPDATE_PROMPT, + TURN_PLAN_UPDATE_SCENARIO, + TURN_PLAN_UPDATE_STEPS, +}; diff --git a/scripts/agent-runtime/claw-chat-current-fixture-typed-error.mjs b/scripts/agent-runtime/claw-chat-current-fixture-typed-error.mjs new file mode 100644 index 000000000..dc3e2538b --- /dev/null +++ b/scripts/agent-runtime/claw-chat-current-fixture-typed-error.mjs @@ -0,0 +1,382 @@ +import fs from "node:fs"; +import { + TYPED_ERROR_RETRY_FAILURE_ERROR_TEXT, + TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT, + TYPED_ERROR_RETRY_FAILURE_PROMPT, + TYPED_ERROR_RETRY_FAILURE_SCENARIO, + TYPED_ERROR_RETRY_SUCCESS_DONE_TEXT, + TYPED_ERROR_RETRY_SUCCESS_PROMPT, + TYPED_ERROR_RETRY_SUCCESS_SCENARIO, + TYPED_ERROR_RETRY_SUCCESS_TEXT, +} from "./claw-chat-current-fixture-constants.mjs"; +import { sendPromptFromGui } from "./claw-chat-current-fixture-gui-actions.mjs"; +import { waitForGuiChatCompleted } from "./claw-chat-current-fixture-gui-completion-waits.mjs"; +import { + waitForSessionReadCompleted, + waitForSessionReadFailedAfterAnswer, +} from "./claw-chat-current-fixture-read-model-waits.mjs"; +import { readModelLatestTurnStatus } from "./claw-chat-current-fixture-read-model-core.mjs"; +import { + evaluatePageSnapshot, + invokeAppServerFromPage, +} from "./claw-chat-current-fixture-rpc.mjs"; +import { waitForBackendLedgerEntry } from "./claw-chat-current-fixture-backend-ledger.mjs"; +import { sanitizeJson, sleep } from "./claw-chat-current-fixture-utils.mjs"; + +function appendSignal(signalPath, stage) { + fs.appendFileSync( + signalPath, + `${JSON.stringify({ stage, recordedAt: new Date().toISOString() })}\n`, + ); +} + +function typedErrorStatusSnapshotFromDom({ prompt, phase }) { + const text = document.body?.innerText || ""; + const turnGroups = Array.from( + document.querySelectorAll('[data-testid="message-turn-group"]'), + ); + const group = + [...turnGroups] + .reverse() + .find((candidate) => (candidate.innerText || "").includes(prompt)) ?? + null; + const statusNodes = Array.from( + document.querySelectorAll( + [ + '[data-testid="message-runtime-status-pill"]', + '[data-testid="assistant-first-token-runtime-status"]', + '[data-testid="inputbar-runtime-status-line"]', + ].join(","), + ), + ); + const statusEntries = statusNodes.map((node) => ({ + testId: node.getAttribute("data-testid") || "", + text: node.textContent || "", + ariaLabel: node.getAttribute("aria-label") || "", + status: node.getAttribute("data-status") || "", + })); + const statusText = statusEntries + .map((entry) => [entry.text, entry.ariaLabel, entry.status].join(" ")) + .join("\n"); + const hasExpectedPhase = statusEntries.some( + (entry) => entry.status === phase, + ); + const textarea = document.querySelector( + 'textarea[name="agent-chat-message"]', + ); + const textareaRect = textarea?.getBoundingClientRect(); + const textareaStyle = textarea ? window.getComputedStyle(textarea) : null; + const textareaVisible = Boolean( + textarea && + textareaRect && + textareaRect.width > 16 && + textareaRect.height > 16 && + textareaStyle?.visibility !== "hidden" && + textareaStyle?.display !== "none", + ); + const stopButtonVisible = Array.from( + document.querySelectorAll("button"), + ).some((button) => { + const label = [ + button.getAttribute("title") || "", + button.textContent || "", + button.getAttribute("aria-label") || "", + ].join(" "); + return ( + !button.disabled && + (label.includes("停止") || + label.includes("终止") || + /\bStop\b/i.test(label)) + ); + }); + return { + url: window.location.href, + hasPrompt: text.includes(prompt), + hasExpectedPhase, + statusEntries, + statusText, + stopButtonVisible, + textareaVisible, + textareaDisabled: + textarea instanceof HTMLTextAreaElement ? textarea.disabled : null, + bodyText: text, + scopedText: group?.innerText || text, + }; +} + +async function waitForTypedErrorGuiStatus(page, options, { prompt, phase }) { + const startedAt = Date.now(); + let lastSnapshot = null; + while (Date.now() - startedAt < options.timeoutMs) { + const snapshot = await evaluatePageSnapshot( + page, + typedErrorStatusSnapshotFromDom, + { prompt, phase }, + ); + lastSnapshot = snapshot; + if (snapshot?.hasPrompt && snapshot.hasExpectedPhase) { + return sanitizeJson(snapshot); + } + await sleep(options.intervalMs); + } + throw new Error( + `Claw GUI 未观察到 typed error 状态: ${JSON.stringify( + sanitizeJson(lastSnapshot), + )}`, + ); +} + +async function waitForReadModelTypedErrorPending( + page, + options, + requestLog, + { prompt }, +) { + const startedAt = Date.now(); + let lastRead = null; + while (Date.now() - startedAt < options.timeoutMs) { + const response = await invokeAppServerFromPage( + page, + "thread/read", + { threadId: options.threadId, includeTurns: true }, + requestLog, + ); + const read = response.result; + lastRead = read; + const serialized = JSON.stringify(read || {}); + const status = String(readModelLatestTurnStatus(read) || "").toLowerCase(); + if ( + serialized.includes(prompt) && + !["completed", "failed", "interrupted", "canceled", "cancelled"].includes( + status, + ) + ) { + return sanitizeJson({ + latestTurnStatus: readModelLatestTurnStatus(read), + includesPrompt: true, + terminal: false, + }); + } + await sleep(options.intervalMs); + } + throw new Error( + `App Server read model 在 typed error 后提前进入终态或未保留输入: ${JSON.stringify( + sanitizeJson(lastRead), + )}`, + ); +} + +export function buildTypedErrorScenarioAssertions({ + isTypedErrorRetryFailureScenario, + isTypedErrorRetrySuccessScenario, + summary, + typedErrorTurnStart, +}) { + const retryBackend = summary.typedErrorRetryingBackend ?? {}; + const terminalBackend = summary.typedErrorTerminalBackend ?? {}; + const retryGui = summary.typedErrorRetryingGui ?? {}; + const awaitingGui = summary.typedErrorAwaitingTerminalGui ?? {}; + const terminalGui = summary.guiTypedErrorTerminal ?? {}; + const terminalRead = summary.readModelTypedErrorTerminal ?? {}; + const pendingRead = summary.readModelTypedErrorPending ?? {}; + const retryTypes = retryBackend.eventTypes ?? []; + const expectedPrompt = isTypedErrorRetrySuccessScenario + ? TYPED_ERROR_RETRY_SUCCESS_PROMPT + : TYPED_ERROR_RETRY_FAILURE_PROMPT; + return { + typedErrorRetryPromptReachedBackend: + typedErrorTurnStart?.inputText === expectedPrompt && + typeof typedErrorTurnStart?.sessionId === "string" && + typeof typedErrorTurnStart?.turnId === "string", + typedErrorRetryBackendEventOrder: + retryTypes.length === (isTypedErrorRetryFailureScenario ? 2 : 1) && + retryTypes.every((eventType) => eventType === "plugin_worker.retry") && + retryBackend.expectedWillRetry === true, + typedErrorRetryGuiRetryingStatusVisible: + retryGui.hasPrompt === true && retryGui.hasExpectedPhase === true, + typedErrorRetryNoPrematureTerminal: + retryGui.stopButtonVisible === true && + retryGui.textareaDisabled === false, + typedErrorRetrySuccessGuiCompleted: isTypedErrorRetrySuccessScenario + ? terminalGui.hasPrompt === true && + terminalGui.bodyText?.includes(TYPED_ERROR_RETRY_SUCCESS_TEXT) === + true && + terminalGui.bodyText?.includes(TYPED_ERROR_RETRY_SUCCESS_DONE_TEXT) === + true && + terminalGui.stopButtonVisible === false && + terminalGui.textareaDisabled === false + : true, + typedErrorRetrySuccessReadModelCompleted: isTypedErrorRetrySuccessScenario + ? terminalRead.latestTurnStatus === "completed" && + terminalRead.includesPrompt === true && + terminalRead.includesSuccessText === true + : true, + typedErrorRetryFailureGuiAwaitedTerminal: isTypedErrorRetryFailureScenario + ? awaitingGui.hasPrompt === true && + awaitingGui.hasExpectedPhase === true && + awaitingGui.stopButtonVisible === true + : true, + typedErrorRetryFailureGuiCompleted: isTypedErrorRetryFailureScenario + ? terminalGui.hasPrompt === true && + terminalGui.bodyText?.includes( + TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT, + ) === true && + terminalGui.stopButtonVisible === false && + terminalGui.textareaDisabled === false + : true, + typedErrorRetryFailureReadModelFailed: isTypedErrorRetryFailureScenario + ? terminalRead.latestTurnStatus === "failed" && + terminalRead.includesPrompt === true && + terminalRead.includesPartialText === true && + terminalRead.includesFailureText === true + : true, + typedErrorRetryFailureNoPrematureTerminal: isTypedErrorRetryFailureScenario + ? terminalBackend.eventType === "turn.failed" && + summary.typedErrorAwaitingTerminalBackend?.eventType === + "runtime.error" && + summary.typedErrorAwaitingTerminalBackend?.expectedWillRetry === false && + pendingRead.includesPrompt === true && + pendingRead.terminal === false + : true, + typedErrorRetryIdentityConsistent: + typedErrorTurnStart?.sessionId === summary.sessionId && + typedErrorTurnStart?.turnId === terminalBackend.turnId && + terminalGui.completionScope?.runtimeTurnId === + typedErrorTurnStart?.turnId, + }; +} + +export async function runTypedErrorScenario({ + page, + options, + appServerRequests, + runtimeEnv, + logStage, +}) { + const isSuccess = options.scenario === TYPED_ERROR_RETRY_SUCCESS_SCENARIO; + const prompt = isSuccess + ? TYPED_ERROR_RETRY_SUCCESS_PROMPT + : TYPED_ERROR_RETRY_FAILURE_PROMPT; + const result = {}; + + logStage("send-typed-error-prompt-from-gui"); + result.typedErrorRetryInputSend = sanitizeJson( + await sendPromptFromGui(page, options, prompt), + ); + + logStage("wait-typed-error-retrying-backend"); + const retryingBackend = await waitForBackendLedgerEntry( + runtimeEnv.backendLedgerPath, + (entry) => entry.kind === "typedErrorRetryingAwaitingSignal", + options, + ); + result.typedErrorRetryingBackend = sanitizeJson(retryingBackend.entry); + result.typedErrorTurnStart = { + sessionId: retryingBackend.entry.sessionId, + turnId: retryingBackend.entry.turnId, + inputText: prompt, + requireBackendTurn: true, + }; + + logStage("wait-typed-error-retrying-gui"); + result.typedErrorRetryingGui = sanitizeJson( + await waitForTypedErrorGuiStatus(page, options, { + prompt, + phase: "retrying", + }), + ); + appendSignal(runtimeEnv.typedErrorSignalPath, "retry-visible"); + + if (isSuccess) { + logStage("wait-typed-error-success-gui-completed"); + result.guiTypedErrorTerminal = sanitizeJson( + await waitForGuiChatCompleted(page, options, { + prompt, + summaryText: TYPED_ERROR_RETRY_SUCCESS_TEXT, + doneText: TYPED_ERROR_RETRY_SUCCESS_DONE_TEXT, + }), + ); + logStage("wait-typed-error-success-read-model-completed"); + const readModel = await waitForSessionReadCompleted( + page, + options, + appServerRequests, + { + prompt, + summaryText: TYPED_ERROR_RETRY_SUCCESS_TEXT, + doneText: TYPED_ERROR_RETRY_SUCCESS_DONE_TEXT, + }, + ); + const serialized = JSON.stringify(readModel || {}); + result.readModelTypedErrorTerminal = sanitizeJson({ + latestTurnStatus: readModelLatestTurnStatus(readModel), + includesPrompt: serialized.includes(prompt), + includesSuccessText: serialized.includes(TYPED_ERROR_RETRY_SUCCESS_TEXT), + includesDoneText: serialized.includes( + TYPED_ERROR_RETRY_SUCCESS_DONE_TEXT, + ), + }); + } else { + logStage("wait-typed-error-awaiting-terminal-backend"); + const awaitingTerminal = await waitForBackendLedgerEntry( + runtimeEnv.backendLedgerPath, + (entry) => entry.kind === "typedErrorAwaitingTerminalSignal", + options, + ); + result.typedErrorAwaitingTerminalBackend = sanitizeJson( + awaitingTerminal.entry, + ); + logStage("wait-typed-error-awaiting-terminal-gui"); + result.typedErrorAwaitingTerminalGui = sanitizeJson( + await waitForTypedErrorGuiStatus(page, options, { + prompt, + phase: "failed", + }), + ); + result.readModelTypedErrorPending = + await waitForReadModelTypedErrorPending(page, options, appServerRequests, { + prompt, + }); + appendSignal(runtimeEnv.typedErrorSignalPath, "terminal-visible"); + + logStage("wait-typed-error-failure-gui-completed"); + result.guiTypedErrorTerminal = sanitizeJson( + await waitForGuiChatCompleted(page, options, { + prompt, + summaryText: TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT, + dedupeGuardTexts: [TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT], + }), + ); + logStage("wait-typed-error-failure-read-model-failed"); + const readModel = await waitForSessionReadFailedAfterAnswer( + page, + options, + appServerRequests, + { + prompt, + partialText: TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT, + failureText: TYPED_ERROR_RETRY_FAILURE_ERROR_TEXT, + }, + ); + const serialized = JSON.stringify(readModel || {}); + result.readModelTypedErrorTerminal = sanitizeJson({ + latestTurnStatus: readModelLatestTurnStatus(readModel), + includesPrompt: serialized.includes(prompt), + includesPartialText: serialized.includes( + TYPED_ERROR_RETRY_FAILURE_PARTIAL_TEXT, + ), + includesFailureText: serialized.includes( + TYPED_ERROR_RETRY_FAILURE_ERROR_TEXT, + ), + }); + } + + logStage("wait-typed-error-terminal-backend"); + const terminal = await waitForBackendLedgerEntry( + runtimeEnv.backendLedgerPath, + (entry) => entry.kind === "typedErrorTerminalEmitted", + options, + ); + result.typedErrorTerminalBackend = sanitizeJson(terminal.entry); + return result; +} diff --git a/scripts/agent-runtime/current-fixture-regression-smoke.mjs b/scripts/agent-runtime/current-fixture-regression-smoke.mjs index cc0218677..956dc9a00 100644 --- a/scripts/agent-runtime/current-fixture-regression-smoke.mjs +++ b/scripts/agent-runtime/current-fixture-regression-smoke.mjs @@ -132,6 +132,7 @@ function main() { runVitest("Agent 流式完成与运行态收尾", [ "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts", "src/components/agent/chat/hooks/agentStreamRuntimeHandler.unit.test.ts", + "src/components/agent/chat/hooks/agentStreamRuntimeHandler.typedError.test.ts", "-t", "turn_completed|usage|工具", ]); @@ -402,6 +403,34 @@ function main() { options, ); + runElectronFixtureSmoke( + "Claw typed error retry success Electron fixture", + [ + "scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs", + "--scenario", + "typed-error-retry-success", + "--prefix", + "claw-chat-current-fixture-typed-error-retry-success-regression", + "--timeout-ms", + "180000", + ], + options, + ); + + runElectronFixtureSmoke( + "Claw typed error retry failure Electron fixture", + [ + "scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs", + "--scenario", + "typed-error-retry-failure", + "--prefix", + "claw-chat-current-fixture-typed-error-retry-failure-regression", + "--timeout-ms", + "180000", + ], + options, + ); + runElectronFixtureSmoke( "Content Factory article Article Editor Electron fixture", [ @@ -417,7 +446,7 @@ function main() { ); console.log( - `[${LOG_PREFIX}] summary: current Agent Runtime fixture regression 已覆盖 history/cache hydration、turn_completed 工具收尾、failed read model、Claw 终态 UI、Electron fixture guard、首页首发热路径用户消息可见/无空对话/无模型回退 Electron fixture、真实 GUI coding 输入到 Coding Workbench Electron fixture、真实 GUI 图片命令到 Claw Chat Electron fixture、普通自然语言画图意图到同一图片 task 主链 Electron fixture、Claw GUI current fixture guard、停止后同会话继续输出 Electron fixture、approval allow-for-session resume / decline continue / cancel turn 三类 Electron fixture、approval full-access 不弹 prompt / 不生成 timeline record Electron fixture、Inputbar rich draft 在 output-free cancel 后恢复 text/image/path/skill Electron fixture、active turn 经 thread/read -> turn/steer 保持同一 Turn identity Electron fixture、Plan revisioned thread item + history hydrate Electron fixture、Skills Runtime natural + 显式 $skill + 技能中心试用入口三入口按需加载 Electron fixture、MCP structuredContent 到 Agent Chat GUI 可见 Electron fixture、media item / imageView 引用到 Agent Chat 卡片与 Workbench source 预览 Electron fixture、Expert Plaza 点击专家卡片进入同一 Skills Runtime 闭环 Electron fixture、ExpertInfoPanel 调整 skillRefs 后下一轮继承同一 Skills Runtime 闭环并展示 Evidence Pack 复盘 Electron fixture、内容工厂文章 Article Editor / articleDraft 右侧产物闭环 Electron fixture;liveProviderUsed=false`, + `[${LOG_PREFIX}] summary: current Agent Runtime fixture regression 已覆盖 history/cache hydration、turn_completed 工具收尾、typed error retry success/failure、failed read model、Claw 终态 UI、Electron fixture guard、首页首发热路径用户消息可见/无空对话/无模型回退 Electron fixture、真实 GUI coding 输入到 Coding Workbench Electron fixture、真实 GUI 图片命令到 Claw Chat Electron fixture、普通自然语言画图意图到同一图片 task 主链 Electron fixture、Claw GUI current fixture guard、停止后同会话继续输出 Electron fixture、approval allow-for-session resume / decline continue / cancel turn 三类 Electron fixture、approval full-access 不弹 prompt / 不生成 timeline record Electron fixture、Inputbar rich draft 在 output-free cancel 后恢复 text/image/path/skill Electron fixture、active turn 经 thread/read -> turn/steer 保持同一 Turn identity Electron fixture、Plan revisioned thread item + history hydrate Electron fixture、Skills Runtime natural + 显式 $skill + 技能中心试用入口三入口按需加载 Electron fixture、MCP structuredContent 到 Agent Chat GUI 可见 Electron fixture、media item / imageView 引用到 Agent Chat 卡片与 Workbench source 预览 Electron fixture、Expert Plaza 点击专家卡片进入同一 Skills Runtime 闭环 Electron fixture、ExpertInfoPanel 调整 skillRefs 后下一轮继承同一 Skills Runtime 闭环并展示 Evidence Pack 复盘 Electron fixture、内容工厂文章 Article Editor / articleDraft 右侧产物闭环 Electron fixture;liveProviderUsed=false`, ); console.log(`\n[${LOG_PREFIX}] 通过`); } diff --git a/scripts/agent-runtime/current-fixture-regression-smoke.test.mjs b/scripts/agent-runtime/current-fixture-regression-smoke.test.mjs index 99551ffe8..93d7cb4c9 100644 --- a/scripts/agent-runtime/current-fixture-regression-smoke.test.mjs +++ b/scripts/agent-runtime/current-fixture-regression-smoke.test.mjs @@ -50,11 +50,33 @@ describe("agent runtime current fixture regression smoke guard", () => { expect(content).toContain( "src/components/agent/chat/hooks/agentStreamRuntimeHandler.unit.test.ts", ); + expect(content).toContain( + "src/components/agent/chat/hooks/agentStreamRuntimeHandler.typedError.test.ts", + ); expect(content).toContain( "src/components/agent/chat/components/MessageList.test.tsx", ); }); + it("runs both typed error Electron Gate B fixtures", () => { + const content = readSmokeScript(); + + expect(content).toContain( + "Claw typed error retry success Electron fixture", + ); + expect(content).toContain("typed-error-retry-success"); + expect(content).toContain( + "claw-chat-current-fixture-typed-error-retry-success-regression", + ); + expect(content).toContain( + "Claw typed error retry failure Electron fixture", + ); + expect(content).toContain("typed-error-retry-failure"); + expect(content).toContain( + "claw-chat-current-fixture-typed-error-retry-failure-regression", + ); + }); + it("keeps active turn steer in the exact Electron regression matrix", () => { const content = readSmokeScript(); diff --git a/scripts/agent-runtime/skills-runtime-fixture-scenario.mjs b/scripts/agent-runtime/skills-runtime-fixture-scenario.mjs index 9fc9a9b21..ea61f0a8f 100644 --- a/scripts/agent-runtime/skills-runtime-fixture-scenario.mjs +++ b/scripts/agent-runtime/skills-runtime-fixture-scenario.mjs @@ -25,6 +25,11 @@ export const EXPERT_SKILLS_RUNTIME_PANEL_PROMPT = export const EXPERT_SKILLS_RUNTIME_PANEL_DONE_TEXT = "CLAW_EXPERT_SKILLS_RUNTIME_PANEL_DONE"; export const SKILLS_RUNTIME_ASSERTION_KEYS = [ + "initialCurrentSkillListObserved", + "typedSkillsChangedConsumed", + "automaticSkillListRefreshObserved", + "guiSkillCatalogUpdated", + "skillsCatalogRefreshDidNotUseManualRefresh", "skillsRuntimePromptReachedBackend", "guiSkillsRuntimeInputSubmitted", "guiSkillsRuntimeCompleted", diff --git a/scripts/check-app-server-client-contract.mjs b/scripts/check-app-server-client-contract.mjs index 5f81ee462..4c27b1b57 100644 --- a/scripts/check-app-server-client-contract.mjs +++ b/scripts/check-app-server-client-contract.mjs @@ -1042,6 +1042,53 @@ const checks = [ "isAppServerNotificationMethod(METHOD_WARNING), true", ], }, + { + name: "runtime errors stay typed from App Server retry policy through Renderer state", + files: [ + "lime-rs/crates/app-server-protocol/src/protocol/v2/common.rs", + "lime-rs/crates/app-server-protocol/src/protocol/v2/notification.rs", + "lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs", + "lime-rs/crates/app-server-protocol/schema/json/v2/ErrorNotification.json", + appServerGeneratedProtocolFile, + "packages/app-server-client/src/server-notifications.ts", + "packages/app-server-client/src/agent-runtime.ts", + "lime-rs/crates/app-server/src/processor/v2_notifications.rs", + "lime-rs/crates/app-server/src/processor/v2_notifications/error.rs", + "src/lib/api/agentRuntime/appServerV2Notification.ts", + "src/components/agent/chat/hooks/agentStreamTypedErrorController.ts", + "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts", + ], + snippets: [ + "pub enum CodexErrorInfo", + '#[serde(rename = "httpStatusCode")]', + '#[serde(rename = "turnKind")]', + "pub struct ErrorNotification", + "Error(ErrorNotification)", + 'export const METHOD_ERROR = "error"', + "export interface ErrorNotification", + "httpStatusCode?: number | null;", + "turnKind: NonSteerableTurnKind;", + '"plugin_worker.retry" => return self.project_error(event, Some(true))', + '"runtime.error" => return self.project_error(event, None)', + "error::project(event, Some(false))", + "ServerNotification::Error", + "isErrorNotification(notification)", + "notification && !isAgentRuntimeSignalNotification(notification)", + 'params.event.protocol_method !== "error"', + 'typeof params.event.will_retry !== "boolean"', + "buildAgentStreamTypedErrorPlan", + ], + }, + { + name: "Renderer turn binding cannot restore raw runtime error fallback", + file: "src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts", + snippets: ["handleTurnStreamEvent"], + absentSnippets: [ + '"runtime_error"', + '"runtime.error"', + "readRuntimeErrorMessage", + ], + }, { name: "Rust App Server gates AgentUI runtime events before storage", files: [ @@ -4779,6 +4826,25 @@ const checks = [ '"agentSession/delete"', ], }, + { + name: "skills/changed keeps a strict generated notification contract", + files: [ + "lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs", + "lime-rs/crates/app-server-protocol/src/protocol/v2/notification.rs", + "lime-rs/crates/app-server-protocol/schema/json/v2/SkillsChangedNotification.json", + "packages/app-server-client/src/generated/protocol-types.ts", + "packages/app-server-client/src/server-notifications.ts", + ], + snippets: [ + 'METHOD_SKILLS_CHANGED: &str = "skills/changed"', + "pub struct SkillsChangedNotification {}", + '"additionalProperties": false', + 'export const METHOD_SKILLS_CHANGED = "skills/changed"', + "export type SkillsChangedNotification = Record;", + "skillsChangedServerNotification", + "Object.keys(params).length !== 0", + ], + }, { name: "media/read is the only production media sidecar read contract", files: [ diff --git a/scripts/electron/mcp-oauth-notification-fixture-smoke.mjs b/scripts/electron/mcp-oauth-notification-fixture-smoke.mjs new file mode 100644 index 000000000..1130b934c --- /dev/null +++ b/scripts/electron/mcp-oauth-notification-fixture-smoke.mjs @@ -0,0 +1,535 @@ +#!/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 { + APP_SERVER_HANDLE_JSON_LINES_COMMAND, + LEGACY_MCP_COMMANDS, + writeJsonFile, +} from "../mcp/lib/current-smoke-transport.mjs"; +import { startMcpOAuthFixtureProvider } from "../mcp/oauth-fixture-smoke.mjs"; +import { + appServerCallFromPage, + assert, + closeElectronFixture, + createTempRuntimeEnv, + launchElectronFixture, + openSettings, + parseInvokeTraceRaw, + parseJsonRpcRequestsFromInvokeTrace, + sanitizeText, + sleep, +} 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 OPEN_EXTERNAL_URL_COMMAND = "open_external_url"; +const OAUTH_COMPLETED_METHOD = "mcpServer/oauthLogin/completed"; +const REQUIRED_POST_LOGIN_METHODS = [ + "mcpServer/oauth/login", + "mcpServerStatus/list", + "mcpTool/list", +]; +const OAUTH_REQUIRED_COPY = + /需要授权|需要授權|Authorization required|認可が必要|인증 필요/i; +const OAUTH_AUTHORIZED_COPY = /已授权|已授權|Authorized|認可済み|인증됨/i; +const OAUTH_COMPLETED_COPY = + /授权已完成|授權已完成|authorization completed|認可が完了|인증이 완료/i; +const EXTERNAL_URL_CAPTURE_KEY = "__limeMcpOauthExternalUrls"; + +const DEFAULTS = { + runId: process.env.LIME_GATE_RUN_ID?.trim() || null, + evidenceDir: path.join( + process.cwd(), + ".lime", + "qc", + "mcp-oauth-notification", + ), + prefix: "mcp-oauth-notification-fixture", + timeoutMs: 120_000, + intervalMs: 250, + keepTemp: false, +}; + +const LOG_PREFIX = "[smoke:mcp-oauth-notification-fixture]"; + +function printHelp() { + console.log(` +MCP OAuth Notification Electron Fixture Smoke + +用途: + 从真实 Electron 设置页点击 MCP OAuth 登录,使用本地 OAuth provider 完成回调, + 并证明 App Server typed notification 自动刷新 GUI 授权状态和完成提示。 + +边界: + 系统浏览器打开动作在隔离 Electron fixture 中被捕获,再由测试进程跟随同一授权 URL; + OAuth runtime、HTTP provider、callback、App Server event drain 与 Renderer 均走 current 主链。 + +用法: + npm run smoke:mcp-oauth-notification-electron-fixture + +选项: + --run-id --evidence-dir --prefix --timeout-ms + --interval-ms --keep-temp -h|--help +`); +} + +function logStage(stage) { + console.log(`${LOG_PREFIX} stage=${stage}`); +} + +async function installExternalUrlCapture(app) { + await app.evaluate(({ shell }, captureKey) => { + globalThis[captureKey] = []; + shell.openExternal = async (url) => { + globalThis[captureKey].push(String(url)); + }; + }, EXTERNAL_URL_CAPTURE_KEY); +} + +async function waitForCapturedAuthorizationUrl(app, provider, options) { + const startedAt = Date.now(); + while (Date.now() - startedAt < Math.min(45_000, options.timeoutMs)) { + const urls = await app.evaluate( + (_electron, captureKey) => globalThis[captureKey] ?? [], + EXTERNAL_URL_CAPTURE_KEY, + ); + const authorizationUrl = urls.find((url) => + String(url).startsWith(`${provider.baseUrl}/authorize`), + ); + if (authorizationUrl) { + return authorizationUrl; + } + await sleep(options.intervalMs); + } + throw new Error("GUI 登录未通过 Electron open_external_url 打开授权地址"); +} + +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(); +} + +function serverCard(page, serverName) { + return page + .getByText(serverName, { exact: true }) + .first() + .locator( + 'xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " border ")][1]', + ); +} + +async function waitForServerState(page, serverName, copy, options) { + const card = serverCard(page, serverName); + await card.waitFor({ + state: "visible", + timeout: Math.min(45_000, options.timeoutMs), + }); + await card.getByText(copy).waitFor({ + state: "visible", + timeout: Math.min(45_000, options.timeoutMs), + }); + return card; +} + +async function clickOauthLogin(page, serverName, options) { + const card = await waitForServerState( + page, + serverName, + OAUTH_REQUIRED_COPY, + options, + ); + const loginButton = card + .getByRole("button") + .filter({ hasText: /登录|登入|Log in|ログイン|로그인/i }); + await loginButton.waitFor({ + state: "visible", + timeout: Math.min(45_000, options.timeoutMs), + }); + await loginButton.click(); +} + +async function clearInvokeEvidence(page) { + await page.evaluate(() => { + window.localStorage.removeItem("lime_invoke_error_buffer_v1"); + window.localStorage.removeItem("lime_invoke_trace_buffer_v1"); + }); +} + +async function readInvokeEvidence(page) { + return 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, + documentLocale: document.documentElement.lang, + electron: window.__LIME_ELECTRON__ === true, + hasInvokeBridge: typeof window.electronAPI?.invoke === "function", + })); +} + +function summarizeTrace(traceRaw) { + const trace = parseInvokeTraceRaw(traceRaw); + const requests = parseJsonRpcRequestsFromInvokeTrace(traceRaw); + const requestMethods = [ + ...new Set(requests.map((request) => request.method)), + ]; + const commands = [ + ...new Set(trace.map((entry) => entry?.command).filter(Boolean)), + ]; + const relevantCommands = new Set([ + APP_SERVER_HANDLE_JSON_LINES_COMMAND, + APP_SERVER_DRAIN_EVENTS_COMMAND, + OPEN_EXTERNAL_URL_COMMAND, + ]); + const relevantEntries = trace.filter((entry) => + relevantCommands.has(entry?.command), + ); + return { + commands, + requestMethods, + appServerHandleJsonLinesHitCount: trace.filter( + (entry) => entry?.command === APP_SERVER_HANDLE_JSON_LINES_COMMAND, + ).length, + appServerDrainEventsHitCount: trace.filter( + (entry) => entry?.command === APP_SERVER_DRAIN_EVENTS_COMMAND, + ).length, + openExternalUrlHitCount: trace.filter( + (entry) => entry?.command === OPEN_EXTERNAL_URL_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, + missingPostLoginMethods: REQUIRED_POST_LOGIN_METHODS.filter( + (method) => !requestMethods.includes(method), + ), + legacyMcpCommandsSeen: LEGACY_MCP_COMMANDS.filter((command) => + commands.includes(command), + ), + }; +} + +function assertTraceEvidence(evidence) { + assert( + evidence.appServerHandleJsonLinesHitCount > 0, + "未观察到 app_server_handle_json_lines", + ); + assert( + evidence.appServerDrainEventsHitCount > 0, + "未观察到 app_server_drain_events,无法证明 notification drain", + ); + assert( + evidence.openExternalUrlHitCount > 0, + "未观察到 Electron open_external_url", + ); + assert( + evidence.missingPostLoginMethods.length === 0, + `OAuth 完成后缺少自动刷新 method: ${evidence.missingPostLoginMethods.join(", ")}`, + ); + assert(evidence.mockFallbackHitCount === 0, "观察到非 Electron IPC fallback"); + assert(evidence.failedInvokeCount === 0, "观察到 current bridge invoke 失败"); + assert( + evidence.legacyMcpCommandsSeen.length === 0, + `观察到 legacy MCP 命令: ${evidence.legacyMcpCommandsSeen.join(", ")}`, + ); +} + +function assertProviderEvidence(provider) { + const authorizeQuery = provider.state.authorizeQueries.at(-1) ?? {}; + assert( + provider.state.authorizeQueries.length > 0, + "本地 OAuth provider 未收到 authorize 请求", + ); + assert( + authorizeQuery.scope === "fixture.read", + `OAuth scope 漂移: ${authorizeQuery.scope || ""}`, + ); + assert( + provider.state.tokenRequests.some( + (request) => + request.grant_type === "authorization_code" && + request.code === "fixture-auth-code", + ), + "本地 OAuth provider 未收到 authorization_code token 请求", + ); +} + +async function deleteFixtureServer(page, serverId) { + if (!page || page.isClosed()) { + return; + } + await appServerCallFromPage(page, "mcpServer/delete", { id: serverId }).catch( + (error) => { + console.warn( + `${LOG_PREFIX} fixture cleanup failed: ${sanitizeText( + error instanceof Error ? error.message : String(error), + )}`, + ); + }, + ); +} + +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: true, + }); + const appServerEnv = resolveElectronAppServerRuntimeEnv({ + env: { + ...runtimeEnv.env, + APP_SERVER_BIN: appServerBinary, + }, + }); + const provider = await startMcpOAuthFixtureProvider(); + const serverId = `mcp-oauth-notification-${Date.now()}`; + const serverName = serverId.replace(/[^a-zA-Z0-9_-]/g, "-"); + const consoleErrors = []; + const pageErrors = []; + const summary = { + schemaVersion: 1, + scenarioId: "MCP-OAUTH-01-typed-notification", + proofLevel: "Gate B", + claimBoundary: + "Real Electron/preload/IPC, App Server OAuth login and event drain, local OAuth provider callback, and automatic Renderer status/toast projection. System browser navigation is intercepted test-only and followed by the fixture process.", + testOnly: true, + ok: false, + startedAt: new Date().toISOString(), + completedAt: null, + pageUrl: null, + documentLocale: null, + serverName, + notificationMethod: OAUTH_COMPLETED_METHOD, + electronPreloadBridge: false, + oauthRequiredVisible: false, + oauthAuthorizedVisible: false, + completionToastVisible: false, + provider: { + host: new URL(provider.baseUrl).host, + authorizeRequestCount: 0, + tokenRequestCount: 0, + scope: null, + }, + trace: null, + consoleErrors, + pageErrors, + invokeErrorCount: 0, + screenshot: null, + tempRoot: options.keepTemp ? runtimeEnv.tempRoot : null, + failure: null, + }; + + let handle = null; + let page = null; + try { + logStage("launch-electron"); + handle = await launchElectronFixture({ + options, + runtimeEnv, + appServerEnv, + consoleErrors, + pageErrors, + backendMode: "unavailable", + }); + page = handle.page; + summary.electronPreloadBridge = + handle.rendererSnapshot.electron && + handle.rendererSnapshot.hasInvokeBridge; + + logStage("capture-external-browser-url"); + await installExternalUrlCapture(handle.app); + + logStage("create-oauth-server"); + const createResult = await appServerCallFromPage(page, "mcpServer/create", { + server: { + id: serverId, + name: serverName, + description: "MCP OAuth typed notification fixture", + server_config: { + transport: "streamable_http", + url: provider.mcpUrl, + timeout: 3, + scopes: ["fixture.read"], + }, + enabled_lime: true, + enabled_claude: false, + enabled_codex: false, + enabled_gemini: false, + created_at: Date.now(), + }, + }); + assert( + Array.isArray(createResult.result?.servers), + "mcpServer/create 未返回 servers", + ); + + logStage("open-mcp-runtime-settings"); + await openMcpRuntimeSettings(page, options); + await waitForServerState(page, serverName, OAUTH_REQUIRED_COPY, options); + summary.oauthRequiredVisible = true; + + await clearInvokeEvidence(page); + + logStage("click-oauth-login"); + await clickOauthLogin(page, serverName, options); + const authorizationUrl = await waitForCapturedAuthorizationUrl( + handle.app, + provider, + options, + ); + + const authorizedState = waitForServerState( + page, + serverName, + OAUTH_AUTHORIZED_COPY, + options, + ); + const completionToast = page + .locator("[data-sonner-toast]") + .filter({ hasText: serverName }) + .filter({ hasText: OAUTH_COMPLETED_COPY }) + .waitFor({ + state: "visible", + timeout: Math.min(45_000, options.timeoutMs), + }); + + logStage("complete-provider-callback"); + const authResponse = await fetch(authorizationUrl, { + redirect: "follow", + signal: AbortSignal.timeout(Math.min(30_000, options.timeoutMs)), + }); + assert( + authResponse.ok, + `OAuth callback 跳转失败: HTTP ${authResponse.status}`, + ); + await Promise.all([authorizedState, completionToast]); + summary.oauthAuthorizedVisible = true; + summary.completionToastVisible = true; + + assertProviderEvidence(provider); + summary.provider.authorizeRequestCount = + provider.state.authorizeQueries.length; + summary.provider.tokenRequestCount = provider.state.tokenRequests.length; + summary.provider.scope = + provider.state.authorizeQueries.at(-1)?.scope ?? null; + + const invokeEvidence = await readInvokeEvidence(page); + const traceEvidence = summarizeTrace(invokeEvidence.traceRaw); + assert(invokeEvidence.electron, "页面不是 Electron renderer"); + assert(invokeEvidence.hasInvokeBridge, "preload invoke bridge 不可用"); + assertTraceEvidence(traceEvidence); + const invokeErrors = parseInvokeTraceRaw(invokeEvidence.errorRaw); + assert( + invokeErrors.length === 0, + `观察到 invoke error: ${invokeErrors + .map((entry) => entry?.error) + .filter(Boolean) + .join(" | ")}`, + ); + 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.documentLocale; + summary.trace = traceEvidence; + summary.invokeErrorCount = invokeErrors.length; + summary.screenshot = path.basename(screenshotPath); + writeJsonFile(summaryPath, summary); + console.log(`${LOG_PREFIX} summary=${summaryPath}`); + console.log(`${LOG_PREFIX} notification=${OAUTH_COMPLETED_METHOD}`); + } catch (error) { + summary.completedAt = new Date().toISOString(); + summary.failure = sanitizeText( + error instanceof Error ? error.message : String(error), + ); + summary.provider.authorizeRequestCount = + provider.state.authorizeQueries.length; + summary.provider.tokenRequestCount = provider.state.tokenRequests.length; + summary.provider.scope = + provider.state.authorizeQueries.at(-1)?.scope ?? null; + if (page && !page.isClosed()) { + try { + const invokeEvidence = await readInvokeEvidence(page); + summary.pageUrl = invokeEvidence.url; + summary.documentLocale = invokeEvidence.documentLocale; + summary.trace = summarizeTrace(invokeEvidence.traceRaw); + summary.invokeErrorCount = parseInvokeTraceRaw( + invokeEvidence.errorRaw, + ).length; + await page.screenshot({ path: failureScreenshotPath, fullPage: true }); + summary.failureScreenshot = path.basename(failureScreenshotPath); + } catch { + // 截图失败不覆盖原始错误。 + } + } + writeJsonFile(summaryPath, summary); + throw error; + } finally { + await deleteFixtureServer(page, serverId); + await provider.close().catch(() => undefined); + if (handle) { + await closeElectronFixture(handle); + } + if (!options.keepTemp) { + fs.rmSync(runtimeEnv.tempRoot, { recursive: true, force: true }); + } + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + run().catch((error) => { + console.error( + `${LOG_PREFIX} failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/generate-protocol-types.mjs b/scripts/generate-protocol-types.mjs index 8c2fd8eab..9b1af6ef7 100644 --- a/scripts/generate-protocol-types.mjs +++ b/scripts/generate-protocol-types.mjs @@ -194,6 +194,9 @@ function objectTypeToTs(schema, indent = 0) { } if (lines.length === 0) { + if (schema.additionalProperties === false) { + return "Record"; + } return "Record"; } diff --git a/scripts/mcp/lib/contract-guards.mjs b/scripts/mcp/lib/contract-guards.mjs index 8ac79c025..88bb50c8a 100644 --- a/scripts/mcp/lib/contract-guards.mjs +++ b/scripts/mcp/lib/contract-guards.mjs @@ -62,6 +62,46 @@ function requiredContractContent(repoRoot, files, content) { return `${content}\n${fs.readFileSync(generatedPath, "utf8")}`; } +function collectSourceFiles(root) { + if (!fs.existsSync(root)) { + return []; + } + const ignoredDirectories = new Set([ + ".git", + "build", + "coverage", + "dist", + "node_modules", + "target", + ]); + const sourceExtensions = new Set([ + ".cjs", + ".js", + ".jsx", + ".json", + ".mjs", + ".rs", + ".ts", + ".tsx", + ]); + const files = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const absolutePath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + pending.push(absolutePath); + } + } else if (sourceExtensions.has(path.extname(entry.name))) { + files.push(absolutePath); + } + } + } + return files; +} + export function checkMcpRuntimeCurrentContracts({ repoRoot, failures }) { const requiredByFile = new Map([ [ @@ -109,6 +149,21 @@ export function checkMcpRuntimeCurrentContracts({ repoRoot, failures }) { "pub struct McpResourceSubscriptionResponse", ], ], + [ + [ + "lime-rs/crates/app-server-protocol/src/protocol/v2/mcp.rs", + "lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs", + "lime-rs/crates/app-server-protocol/src/protocol/v2/envelopes.rs", + "lime-rs/crates/app-server/src/processor/mcp.rs", + ], + [ + 'pub const METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED: &str = "mcpServer/oauthLogin/completed"', + "pub struct McpServerOauthLoginCompletedNotification", + "McpServerOauthLoginCompleted(McpServerOauthLoginCompletedNotification)", + "let (success, error) = match handle.wait().await", + "publish_server_notification(ServerNotification::McpServerOauthLoginCompleted(", + ], + ], [ [ "lime-rs/crates/app-server/src/runtime.rs", @@ -360,10 +415,23 @@ export function checkMcpRuntimeCurrentContracts({ repoRoot, failures }) { "export interface McpResourceContent", ], ], + [ + "packages/app-server-client/src/server-notifications.ts", + [ + 'export type McpServerOauthLoginCompletedServerNotification = Extract<', + '{ method: "mcpServer/oauthLogin/completed" }', + "export function mcpServerOauthLoginCompletedServerNotification(", + 'message.method !== "mcpServer/oauthLogin/completed"', + '!hasOnlyKeys(params, ["error", "name", "success", "threadId"])', + '!hasRequiredNullableString(params, "threadId")', + ], + ], [ "src/hooks/useMcpEvents.ts", [ 'import { safeListen } from "@/lib/api/bridgeEvents"', + 'import { subscribeAppServerNotifications } from "@/lib/api/appServerEventBus"', + "mcpServerOauthLoginCompletedServerNotification", "export async function setupMcpEventListeners(", '"mcp:server_started"', '"mcp:server_stopped"', @@ -371,7 +439,7 @@ export function checkMcpRuntimeCurrentContracts({ repoRoot, failures }) { '"mcp:tools_updated"', '"mcp:resources_updated"', '"mcp:resource_updated"', - '"mcp:oauth_completed"', + "subscribeAppServerNotifications({", ], ], [ @@ -464,6 +532,7 @@ export function checkMcpRuntimeCurrentContracts({ repoRoot, failures }) { "`mcpServer/importFromApp`", "`mcpServer/syncAllToLive`", "`mcpServer/oauth/login`", + "`mcpServer/oauthLogin/completed`", "`mcpServer/start`", "`mcpServer/stop`", "`mcpTool/list`", @@ -577,6 +646,18 @@ export function checkMcpRuntimeCurrentContracts({ repoRoot, failures }) { } } + const legacyOAuthEvent = "mcp:oauth_completed"; + const legacyOAuthEventFiles = ["electron", "lime-rs", "packages", "src"] + .flatMap((root) => collectSourceFiles(path.join(repoRoot, root))) + .filter((file) => fs.readFileSync(file, "utf8").includes(legacyOAuthEvent)); + for (const file of legacyOAuthEventFiles) { + failures.push( + `MCP OAuth completion must use the typed App Server notification: forbidden ${JSON.stringify( + legacyOAuthEvent, + )} in ${path.relative(repoRoot, file)}`, + ); + } + const appServerCurrentContent = [ "lime-rs/crates/app-server/src/runtime.rs", "lime-rs/crates/app-server/src/local_data_source.rs", diff --git a/src/RootRouter.test.tsx b/src/RootRouter.test.tsx index 6b465a0bd..4c17fa780 100644 --- a/src/RootRouter.test.tsx +++ b/src/RootRouter.test.tsx @@ -32,6 +32,10 @@ vi.mock("./features/resource-manager", () => ({ ResourceManagerPage: () =>
, })); +vi.mock("./components/ui/sonner", () => ({ + Toaster: () =>
, +})); + vi.mock("./components/layout/AppCrashBoundary", () => ({ AppCrashBoundary: ({ children }: { children: ReactNode }) => <>{children}, })); @@ -86,7 +90,7 @@ describe("RootRouter", () => { const { container } = await renderRootRouter("/"); expect(container.textContent).toContain("主应用"); - expect(container.querySelector('[data-sonner-toaster]')).toBeNull(); + expect(container.querySelector('[data-testid="toaster"]')).not.toBeNull(); }); it("独立更新窗口从 index.html 入口启动时应映射到更新提醒页", async () => { diff --git a/src/RootRouter.tsx b/src/RootRouter.tsx index d93164668..0df4c80ad 100644 --- a/src/RootRouter.tsx +++ b/src/RootRouter.tsx @@ -8,6 +8,7 @@ import App from "./App"; import { UpdateNotificationPage } from "./pages/update-notification"; import { BrowserRuntimeDebuggerPage } from "./pages"; import { ResourceManagerPage } from "./features/resource-manager"; +import { Toaster } from "./components/ui/sonner"; import { AppCrashBoundary } from "./components/layout/AppCrashBoundary"; import { finalizeCrashRecoveryAutoReload } from "./components/layout/CrashRecoveryPanel.helpers"; import { getRuntimeAppVersion } from "./lib/appVersion"; @@ -81,6 +82,7 @@ export function RootRouter() { return ( + ); } @@ -89,6 +91,7 @@ export function RootRouter() { return ( + ); } @@ -99,6 +102,7 @@ export function RootRouter() { + ); } @@ -107,6 +111,7 @@ export function RootRouter() { return ( + ); } diff --git a/src/components/AppSidebar.preferences.test.tsx b/src/components/AppSidebar.preferences.test.tsx index 6c9ee07a2..1b5a0027b 100644 --- a/src/components/AppSidebar.preferences.test.tsx +++ b/src/components/AppSidebar.preferences.test.tsx @@ -9,6 +9,7 @@ import { flushEffects, mockCheckForUpdates, mockGetConfig, + mockGetUpdateInstallSession, mockOpenUpdateWindow, mountSidebarContainer, resetAppSidebarTest, @@ -87,6 +88,37 @@ describe("AppSidebar preferences", () => { ).not.toBeNull(); }); + it("版本尚未确认的下载会话不应显示更新入口", async () => { + mockGetUpdateInstallSession.mockResolvedValue({ + sessionId: "checking-update", + stage: "downloading", + currentVersion: "1.57.0", + latestVersion: null, + downloadUrl: null, + downloadedBytes: 0, + totalBytes: null, + percent: 0, + message: "正在下载并验证更新", + error: null, + startedAt: 1, + updatedAt: 2, + completedAt: null, + canCloseWindow: true, + isActive: true, + }); + + const container = mountSidebarContainer({ + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(3); + + expect( + container.querySelector('[data-testid="app-sidebar-update-button"]'), + ).toBeNull(); + }); + it("旧 companion 配置不应复活可选系统入口", async () => { mockGetConfig.mockResolvedValue({ navigation: { @@ -319,7 +351,9 @@ describe("AppSidebar preferences", () => { await act(async () => { container - .querySelector('button[aria-label="切换皮肤为奥特曼守护"]') + .querySelector( + 'button[aria-label="切换皮肤为奥特曼守护"]', + ) ?.click(); await Promise.resolve(); }); @@ -377,7 +411,9 @@ describe("AppSidebar preferences", () => { container.querySelector('button[aria-label="Switch theme to Dark"]'), ).not.toBeNull(); expect( - container.querySelector('button[aria-label="Switch skin to Ultraman Guardian"]'), + container.querySelector( + 'button[aria-label="Switch skin to Ultraman Guardian"]', + ), ).not.toBeNull(); }); diff --git a/src/components/agent/chat/components/MessageListRuntimeStatus.test.tsx b/src/components/agent/chat/components/MessageListRuntimeStatus.test.tsx index f472f3213..cf0101cfb 100644 --- a/src/components/agent/chat/components/MessageListRuntimeStatus.test.tsx +++ b/src/components/agent/chat/components/MessageListRuntimeStatus.test.tsx @@ -138,6 +138,7 @@ describe("AssistantFirstTokenRuntimeStatus", () => { ); expect(indicator).not.toBeNull(); + expect(indicator?.getAttribute("data-status")).toBe("context"); expect(indicator?.textContent).toContain("正在准备回复"); expect(indicator?.textContent).not.toContain( "正在整理会话信息和可用资料。", diff --git a/src/components/agent/chat/components/MessageListRuntimeStatus.tsx b/src/components/agent/chat/components/MessageListRuntimeStatus.tsx index 4db0f5d91..404d411d1 100644 --- a/src/components/agent/chat/components/MessageListRuntimeStatus.tsx +++ b/src/components/agent/chat/components/MessageListRuntimeStatus.tsx @@ -40,6 +40,7 @@ export const MessageRuntimeStatusPill: React.FC<{ return (
({ @@ -159,7 +160,13 @@ function renderToolbar( uncommittedFileCount: 0, }); - return mount( + return mount(buildToolbar(props)); +} + +function buildToolbar( + props?: Partial>, +) { + return ( , + /> ); } +function rerenderToolbar( + props?: Partial>, +) { + const mounted = mountedRoots.at(-1); + if (!mounted) { + throw new Error("toolbar is not mounted"); + } + act(() => { + mounted.root.render(buildToolbar(props)); + }); +} + async function flushEffects() { await act(async () => { await Promise.resolve(); @@ -238,6 +257,52 @@ describe("TaskCenterUtilityToolbar plan rail reveal", () => { ]); }); + it("实时 todo checklist 到达时应自动揭示计划轨", async () => { + const container = renderToolbar({ + taskRail: { + workflowSteps: [], + messages: [], + }, + }); + + await flushEffects(); + expect( + container.querySelector( + '[data-testid="task-center-environment-popover"]', + ), + ).toBeNull(); + + rerenderToolbar({ + taskRail: { + workflowSteps: [], + messages: [], + todoItems: [ + { content: "读取 current owner", status: "completed" }, + { content: "验证实时计划", status: "in_progress" }, + { content: "记录 Gate B", status: "pending" }, + ], + }, + }); + await flushEffects(); + + const planSection = container.querySelector( + '[data-testid="task-center-run-control-plan"]', + ); + const planItems = Array.from( + container.querySelectorAll( + '[data-testid="task-center-run-control-plan-item"]', + ), + ); + expect(planSection?.textContent).toContain("读取 current owner"); + expect(planSection?.textContent).toContain("验证实时计划"); + expect(planSection?.textContent).toContain("记录 Gate B"); + expect(planItems.map((item) => item.getAttribute("data-status"))).toEqual([ + "completed", + "running", + "pending", + ]); + }); + it("没有计划项时不应自动打开环境弹窗", async () => { const container = renderToolbar({ taskRail: { @@ -249,7 +314,9 @@ describe("TaskCenterUtilityToolbar plan rail reveal", () => { await flushEffects(); expect( - container.querySelector('[data-testid="task-center-environment-popover"]'), + container.querySelector( + '[data-testid="task-center-environment-popover"]', + ), ).toBeNull(); }); }); diff --git a/src/components/agent/chat/components/TaskCenterUtilityToolbar.tsx b/src/components/agent/chat/components/TaskCenterUtilityToolbar.tsx index 0e8255f1b..85b75350c 100644 --- a/src/components/agent/chat/components/TaskCenterUtilityToolbar.tsx +++ b/src/components/agent/chat/components/TaskCenterUtilityToolbar.tsx @@ -215,6 +215,7 @@ export function TaskCenterUtilityToolbar({ const normalizedProjectRootPath = projectRootPath?.trim() || null; const [environmentVisited, setEnvironmentVisited] = React.useState(false); const [environmentOpen, setEnvironmentOpen] = React.useState(false); + const autoRevealedPlanKeyRef = React.useRef(null); const { status, loading, error } = useProjectGitStatus( environmentVisited ? normalizedProjectRootPath : null, ); @@ -428,17 +429,28 @@ export function TaskCenterUtilityToolbar({ }); }, [environmentOpen, taskRail, taskRailTranslate]); React.useEffect(() => { - if (environmentOpen || !taskRail?.threadItems?.length) { + if (environmentOpen || !taskRail) { return; } - if ( - !hydrateAgentPlanState({ threadItems: taskRail.threadItems }).revisionId - ) { + const todoPlanKey = (taskRail.todoItems ?? []) + .filter((item) => item.content.trim().length > 0) + .map((item) => `${item.content.trim()}\u0000${item.status ?? ""}`) + .join("\u0001"); + const revisionId = taskRail.threadItems?.length + ? hydrateAgentPlanState({ threadItems: taskRail.threadItems }).revisionId + : null; + const planKey = todoPlanKey + ? `todo:${todoPlanKey}` + : revisionId + ? `revision:${revisionId}` + : null; + if (!planKey || autoRevealedPlanKeyRef.current === planKey) { return; } + autoRevealedPlanKeyRef.current = planKey; setEnvironmentOpen(true); setEnvironmentVisited(true); - }, [environmentOpen, taskRail?.threadItems]); + }, [environmentOpen, taskRail?.threadItems, taskRail?.todoItems]); const runControlSurfaceProjection = React.useMemo(() => { if (!taskRailProjection) { return null; diff --git a/src/components/agent/chat/hooks/agentStreamPreparedSendEnv.ts b/src/components/agent/chat/hooks/agentStreamPreparedSendEnv.ts index 8c64817b0..f3bae86ac 100644 --- a/src/components/agent/chat/hooks/agentStreamPreparedSendEnv.ts +++ b/src/components/agent/chat/hooks/agentStreamPreparedSendEnv.ts @@ -1,5 +1,6 @@ import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import type { AgentThreadItem, AgentThreadTurn } from "@/lib/api/agentProtocol"; +import type { AgentTodoItem } from "@/lib/api/agentRuntime/sessionTypes"; import type { AgentExecutionStrategy, AgentSessionExecutionRuntime, @@ -68,6 +69,7 @@ export interface AgentStreamPreparedSendEnv { setMessages: Dispatch>; getThreadItems?: () => readonly AgentThreadItem[]; setThreadItems: Dispatch>; + setTodoItems?: Dispatch>; setThreadTurns: Dispatch>; setCurrentTurnId: Dispatch>; setExecutionRuntime: Dispatch< diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts index dd0000e33..514b0ea4c 100644 --- a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts @@ -111,6 +111,7 @@ import { type TextDeltaAgentEvent, } from "./agentStreamTextDeltaLifecycle"; import { shouldApplyAgentStreamTerminalEvent } from "./agentStreamTerminalTurnGuard"; +import { buildAgentStreamTypedErrorPlan } from "./agentStreamTypedErrorController"; import { applyAgentStreamConversationProjection, reconcileAgentStreamProjectionItems, @@ -189,6 +190,7 @@ export function handleTurnStreamEvent({ setPendingActions, getThreadItems, setThreadItems, + setTodoItems, setThreadTurns, setCurrentTurnId, setExecutionRuntime, @@ -1057,6 +1059,16 @@ export function handleTurnStreamEvent({ activateStream(); break; + case "turn_plan_updated": + activateStream(); + setTodoItems?.( + data.plan.map((item) => ({ + content: item.step, + status: item.status, + })), + ); + break; + case "plan_delta": case "plan_final": { activateStream(); @@ -1534,6 +1546,35 @@ export function handleTurnStreamEvent({ case "error": { flushPendingTextRender(); + const typedErrorPlan = buildAgentStreamTypedErrorPlan({ + event: data, + executionStrategy: effectiveExecutionStrategy, + soulCopy, + }); + if (typedErrorPlan) { + activateStream(); + const runtimeStatusPlan = buildAgentStreamRuntimeStatusApplyPlan({ + status: typedErrorPlan.status, + updatedAt: new Date().toISOString(), + }); + setThreadItems((prev) => { + const nextItems = applyAgentStreamRuntimeStatusToThreadItems({ + activeSessionId, + items: prev, + pendingItemKey, + plan: runtimeStatusPlan, + }); + return nextItems ?? prev; + }); + setMessages((prev) => + applyAgentStreamRuntimeStatusToMessages({ + assistantMsgId, + messages: prev, + plan: runtimeStatusPlan, + }), + ); + break; + } if (isRuntimePermissionConfirmationWaitMessage(data.message)) { clearOptimisticItem(); finishRequestLog(requestState, { diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.turnPlan.test.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.turnPlan.test.ts new file mode 100644 index 000000000..1ddc2ca03 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.turnPlan.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import { parseAgentEvent } from "@/lib/api/agentProtocol"; +import { handleTurnStreamEvent } from "./agentStreamRuntimeHandler"; + +describe("agentStreamRuntimeHandler turn plan signal", () => { + it("只更新 checklist,不创建 ThreadItem 或结束当前流", () => { + const requestState = { + accumulatedContent: "", + requestLogId: null, + requestStartedAt: 0, + requestFinished: false, + }; + const activateStream = vi.fn(); + const setThreadItems = vi.fn(); + const setTodoItems = vi.fn(); + + const data = parseAgentEvent({ + type: "turn_plan_updated", + explanation: "继续执行", + plan: [ + { step: "读现状", status: "completed" }, + { step: "补主链", status: "in_progress" }, + ], + }); + expect(data).not.toBeNull(); + + handleTurnStreamEvent({ + data: data!, + requestState, + callbacks: { + activateStream, + isStreamActivated: () => true, + clearOptimisticItem: vi.fn(), + clearOptimisticTurn: vi.fn(), + disposeListener: vi.fn(), + clearActiveStreamIfMatch: () => true, + appendThinkingToParts: (parts) => parts, + }, + eventName: "turn-plan-signal-test", + pendingTurnKey: "pending-turn", + pendingItemKey: "pending-item", + assistantMsgId: "assistant-1", + activeSessionId: "session-1", + resolvedWorkspaceId: "workspace-1", + effectiveExecutionStrategy: "react", + content: "", + runtime: {} as never, + warnedKeysRef: { current: new Set() }, + actionLoggedKeys: new Set(), + toolLogIdByToolId: new Map(), + toolStartedAtByToolId: new Map(), + toolNameByToolId: new Map(), + setMessages: vi.fn() as never, + setPendingActions: vi.fn() as never, + setThreadItems: setThreadItems as never, + setTodoItems: setTodoItems as never, + setThreadTurns: vi.fn() as never, + setCurrentTurnId: vi.fn() as never, + setExecutionRuntime: vi.fn() as never, + setIsSending: vi.fn() as never, + }); + + expect(activateStream).toHaveBeenCalledOnce(); + expect(setTodoItems).toHaveBeenCalledWith([ + { content: "读现状", status: "completed" }, + { content: "补主链", status: "in_progress" }, + ]); + expect(setThreadItems).not.toHaveBeenCalled(); + expect(requestState.requestFinished).toBe(false); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.typedError.test.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.typedError.test.ts new file mode 100644 index 000000000..770a8dc67 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.typedError.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentEvent, AgentThreadItem } from "@/lib/api/agentProtocol"; +import type { Message } from "../types"; +import { clearAgentUiProjectionEvents } from "../projection/conversationProjectionStore"; +import { handleTurnStreamEvent } from "./agentStreamRuntimeHandler"; +import { clearAllAgentStreamTextOverlays } from "./agentStreamTextOverlayStore"; + +const { mockToast } = vi.hoisted(() => ({ + mockToast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }, +})); + +vi.mock("sonner", () => ({ toast: mockToast })); + +function runTypedError(willRetry: boolean) { + let messages: Message[] = [ + { + content: "partial answer", + contentParts: [{ text: "partial answer", type: "text" }], + id: "assistant-typed-error", + isThinking: true, + role: "assistant", + timestamp: new Date("2026-07-31T00:00:00.000Z"), + }, + ]; + let threadItems: AgentThreadItem[] = [ + { + id: "pending-item", + sequence: 0, + started_at: "2026-07-31T00:00:00.000Z", + status: "in_progress", + text: "running", + thread_id: "thread-typed-error", + turn_id: "turn-typed-error", + type: "turn_summary", + updated_at: "2026-07-31T00:00:00.000Z", + }, + ]; + const callbacks = { + activateStream: vi.fn(), + appendThinkingToParts: vi.fn((parts) => parts), + clearActiveStreamIfMatch: vi.fn(() => true), + clearOptimisticItem: vi.fn(), + clearOptimisticTurn: vi.fn(), + disposeListener: vi.fn(), + isStreamActivated: vi.fn(() => true), + }; + const observer = { + onComplete: vi.fn(), + onError: vi.fn(), + onTextDelta: vi.fn(), + }; + const setIsSending = vi.fn(); + const setMessages = vi.fn( + (value: Message[] | ((current: Message[]) => Message[])) => { + messages = typeof value === "function" ? value(messages) : value; + }, + ); + const setThreadItems = vi.fn( + ( + value: + | AgentThreadItem[] + | ((current: AgentThreadItem[]) => AgentThreadItem[]), + ) => { + threadItems = typeof value === "function" ? value(threadItems) : value; + }, + ); + + handleTurnStreamEvent({ + activeSessionId: "thread-typed-error", + actionLoggedKeys: new Set(), + assistantMsgId: "assistant-typed-error", + callbacks, + content: "user prompt", + data: { + message: willRetry ? "stream reconnecting" : "retry budget exhausted", + protocol_method: "error", + session_id: "thread-typed-error", + thread_id: "thread-typed-error", + turn_id: "turn-typed-error", + type: "error", + will_retry: willRetry, + } as AgentEvent, + effectiveExecutionStrategy: "react", + eventName: "agent-stream-typed-error", + observer, + pendingItemKey: "pending-item", + pendingTurnKey: "pending-turn", + requestState: { + accumulatedContent: "partial answer", + currentTurnId: "turn-typed-error", + requestFinished: false, + requestLogId: null, + requestStartedAt: 0, + }, + resolvedWorkspaceId: "workspace-typed-error", + runtime: {} as never, + setCurrentTurnId: vi.fn() as never, + setExecutionRuntime: vi.fn() as never, + setIsSending: setIsSending as never, + setMessages: setMessages as never, + setPendingActions: vi.fn() as never, + setThreadItems: setThreadItems as never, + setThreadTurns: vi.fn() as never, + toolLogIdByToolId: new Map(), + toolNameByToolId: new Map(), + toolStartedAtByToolId: new Map(), + warnedKeysRef: { current: new Set() }, + }); + + return { callbacks, messages, observer, setIsSending, threadItems }; +} + +describe("agentStreamRuntimeHandler typed error", () => { + afterEach(() => { + clearAgentUiProjectionEvents(); + clearAllAgentStreamTextOverlays(); + for (const toast of Object.values(mockToast)) { + toast.mockReset(); + } + }); + + it.each([ + [true, "retrying", "stream reconnecting"], + [false, "failed", "retry budget exhausted"], + ] as const)( + "willRetry=%s only updates runtime status and waits for turn/completed", + (willRetry, phase, detail) => { + const result = runTypedError(willRetry); + + expect(result.callbacks.activateStream).toHaveBeenCalledTimes(1); + expect(result.messages[0]).toMatchObject({ + content: "partial answer", + isThinking: true, + runtimeStatus: { detail, phase }, + }); + expect(result.threadItems[0]).toMatchObject({ + status: "in_progress", + text: expect.stringContaining(detail), + }); + expect(result.callbacks.clearOptimisticItem).not.toHaveBeenCalled(); + expect(result.callbacks.clearOptimisticTurn).not.toHaveBeenCalled(); + expect(result.callbacks.disposeListener).not.toHaveBeenCalled(); + expect(result.callbacks.clearActiveStreamIfMatch).not.toHaveBeenCalled(); + expect(result.setIsSending).not.toHaveBeenCalled(); + expect(result.observer.onComplete).not.toHaveBeenCalled(); + expect(result.observer.onError).not.toHaveBeenCalled(); + expect(mockToast.error).not.toHaveBeenCalled(); + expect(mockToast.warning).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandlerTypes.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandlerTypes.ts index 837096877..391ef510e 100644 --- a/src/components/agent/chat/hooks/agentStreamRuntimeHandlerTypes.ts +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandlerTypes.ts @@ -8,6 +8,7 @@ import type { AgentExecutionStrategy, AgentSessionExecutionRuntime, } from "@/lib/api/agentExecutionRuntime"; +import type { AgentTodoItem } from "@/lib/api/agentRuntime/sessionTypes"; import type { ActionRequired, Message } from "../types"; import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter"; import type { AgentUiPerformanceTraceMetadata } from "./agentStreamPerformanceMetrics"; @@ -128,6 +129,7 @@ export interface HandleTurnStreamEventOptions { setPendingActions: Dispatch>; getThreadItems?: () => readonly AgentThreadItem[]; setThreadItems: Dispatch>; + setTodoItems?: Dispatch>; setThreadTurns: Dispatch>; setCurrentTurnId: Dispatch>; setExecutionRuntime: Dispatch< diff --git a/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts b/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts index 9047ad0c8..8037a2060 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts @@ -5,6 +5,7 @@ import type { AgentSessionExecutionRuntime, } from "@/lib/api/agentExecutionRuntime"; import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime/sessionTypes"; +import type { AgentTodoItem } from "@/lib/api/agentRuntime/sessionTypes"; import type { TurnSteerParams } from "@limecloud/app-server-client"; import type { ModeKind } from "@limecloud/app-server-client"; import { setThreadGoal } from "@/lib/api/agentRuntime/threadGoalClient"; @@ -129,6 +130,7 @@ interface ExecuteAgentStreamSubmitOptions { setPendingActions: Dispatch>; getThreadItems?: () => readonly AgentThreadItem[]; setThreadItems: Dispatch>; + setTodoItems?: Dispatch>; setThreadTurns: Dispatch>; setCurrentTurnId: Dispatch>; setExecutionRuntime: Dispatch< @@ -249,6 +251,7 @@ export async function executeAgentStreamSubmit( setPendingActions, getThreadItems, setThreadItems, + setTodoItems, setThreadTurns, setCurrentTurnId, setExecutionRuntime, @@ -416,6 +419,7 @@ export async function executeAgentStreamSubmit( setPendingActions, getThreadItems, setThreadItems, + setTodoItems, setThreadTurns, setCurrentTurnId, setExecutionRuntime, diff --git a/src/components/agent/chat/hooks/agentStreamSubmitFailure.ts b/src/components/agent/chat/hooks/agentStreamSubmitFailure.ts index 1af1c3882..09e610d59 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmitFailure.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmitFailure.ts @@ -7,7 +7,7 @@ import { buildFailedAgentMessageContent, buildFailedAgentRuntimeStatus, } from "../utils/agentRuntimeStatus"; -import { resolveAgentRuntimeErrorPresentation } from "../utils/agentRuntimeErrorPresentation"; +import { resolveAgentRuntimeSubmitErrorMessage } from "../utils/agentRuntimeErrorPresentation"; import { isWorkspacePathErrorMessage } from "./agentChatCoreUtils"; import type { ActiveStreamState, @@ -81,12 +81,7 @@ export function handleAgentStreamSubmitFailure( } else if (isWorkspacePathErrorMessage(errMsg)) { setWorkspacePathMissing({ content, images }); } else { - const presentation = resolveAgentRuntimeErrorPresentation(errMsg); - toast.error( - presentation.toastMessage.startsWith("响应错误:") - ? presentation.toastMessage.replace(/^响应错误:/, "发送失败:") - : presentation.toastMessage, - ); + toast.error(resolveAgentRuntimeSubmitErrorMessage(errMsg)); } markOptimisticFailure(errMsg); diff --git a/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts b/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts index 9b928edc2..a36653a28 100644 --- a/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts +++ b/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts @@ -9,7 +9,10 @@ import type { AgentExecutionStrategy, AgentSessionExecutionRuntime, } from "@/lib/api/agentExecutionRuntime"; -import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime/sessionTypes"; +import type { + AgentTodoItem, + AutoContinueRequestPayload, +} from "@/lib/api/agentRuntime/sessionTypes"; import { logAgentDebug } from "@/lib/agentDebug"; import type { ActionRequired, Message } from "../types"; import { handleTurnStreamEvent } from "./agentStreamRuntimeHandler"; @@ -113,31 +116,6 @@ function buildAgentEventPerformanceTrace(params: { }; } -function readRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -function readRuntimeErrorMessage(payload: unknown): string { - const root = readRecord(payload); - const params = readRecord(root?.params); - const event = readRecord(params?.event); - const eventPayload = readRecord(event?.payload); - const directPayload = readRecord(root?.payload); - const source = eventPayload ?? directPayload ?? params ?? root; - const message = - source?.message ?? - source?.errorMessage ?? - source?.error_message ?? - source?.error ?? - event?.event_type ?? - root?.type; - return typeof message === "string" && message.trim() - ? message.trim() - : "Runtime error"; -} - function isFinalAgentMessageSnapshotEvent(event: AgentEvent): boolean { return ( (event.type === "item_started" || @@ -211,6 +189,7 @@ interface RegisterAgentStreamTurnEventBindingOptions { setPendingActions: Dispatch>; getThreadItems?: () => readonly AgentThreadItem[]; setThreadItems: Dispatch>; + setTodoItems?: Dispatch>; setThreadTurns: Dispatch>; setCurrentTurnId: Dispatch>; setExecutionRuntime: Dispatch< @@ -257,6 +236,7 @@ export async function registerAgentStreamTurnEventBinding( setPendingActions, getThreadItems, setThreadItems, + setTodoItems, setThreadTurns, setCurrentTurnId, setExecutionRuntime, @@ -543,6 +523,7 @@ export async function registerAgentStreamTurnEventBinding( setPendingActions, getThreadItems, setThreadItems, + setTodoItems, setThreadTurns, setCurrentTurnId, setExecutionRuntime, @@ -683,10 +664,6 @@ export async function registerAgentStreamTurnEventBinding( ); } if (!data) { - if (eventType === "runtime_error" || eventType === "runtime.error") { - dispatchSyntheticError(readRuntimeErrorMessage(event.payload)); - return; - } if (!terminalRecoveryPollStarted) { clearDeferredRecoveryPoll(); } @@ -821,6 +798,7 @@ export async function registerAgentStreamTurnEventBinding( setPendingActions, getThreadItems, setThreadItems, + setTodoItems, setThreadTurns, setCurrentTurnId, setExecutionRuntime, diff --git a/src/components/agent/chat/hooks/agentStreamTypedErrorController.ts b/src/components/agent/chat/hooks/agentStreamTypedErrorController.ts new file mode 100644 index 000000000..5d900d41f --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamTypedErrorController.ts @@ -0,0 +1,51 @@ +import type { + AgentEvent, + AgentRuntimeStatusPayload, +} from "@/lib/api/agentProtocol"; +import type { AgentExecutionStrategy } from "@/lib/api/agentExecutionRuntime"; +import type { SoulInteractionCopy } from "@/lib/soul/interactionCopy"; +import { + buildFailedAgentRuntimeStatus, + buildWaitingAgentRuntimeStatus, +} from "../utils/agentRuntimeStatus"; + +export interface AgentStreamTypedErrorPlan { + kind: "retrying" | "awaiting_terminal"; + status: AgentRuntimeStatusPayload; +} + +export function buildAgentStreamTypedErrorPlan(params: { + event: Extract; + executionStrategy: AgentExecutionStrategy; + soulCopy?: SoulInteractionCopy; +}): AgentStreamTypedErrorPlan | null { + if ( + params.event.protocol_method !== "error" || + typeof params.event.will_retry !== "boolean" + ) { + return null; + } + + if (!params.event.will_retry) { + return { + kind: "awaiting_terminal", + status: buildFailedAgentRuntimeStatus( + params.event.message, + params.soulCopy, + ), + }; + } + + const waitingStatus = buildWaitingAgentRuntimeStatus({ + executionStrategy: params.executionStrategy, + soulCopy: params.soulCopy, + }); + return { + kind: "retrying", + status: { + ...waitingStatus, + phase: "retrying", + detail: params.event.message, + }, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamTypedErrorController.unit.test.ts b/src/components/agent/chat/hooks/agentStreamTypedErrorController.unit.test.ts new file mode 100644 index 000000000..f422da257 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamTypedErrorController.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import type { AgentEvent } from "@/lib/api/agentProtocol"; +import { buildAgentStreamTypedErrorPlan } from "./agentStreamTypedErrorController"; + +function typedError( + willRetry: boolean, +): Extract { + return { + message: willRetry ? "stream reconnecting" : "retry budget exhausted", + protocol_method: "error", + type: "error", + will_retry: willRetry, + }; +} + +describe("agentStreamTypedErrorController", () => { + it("keeps a retrying error non-terminal", () => { + expect( + buildAgentStreamTypedErrorPlan({ + event: typedError(true), + executionStrategy: "react", + }), + ).toMatchObject({ + kind: "retrying", + status: { + detail: "stream reconnecting", + phase: "retrying", + }, + }); + }); + + it("waits for authoritative turn completion after a terminal error", () => { + expect( + buildAgentStreamTypedErrorPlan({ + event: typedError(false), + executionStrategy: "react", + }), + ).toMatchObject({ + kind: "awaiting_terminal", + status: { + detail: "retry budget exhausted", + phase: "failed", + }, + }); + }); + + it("does not take ownership of legacy synthetic errors", () => { + expect( + buildAgentStreamTypedErrorPlan({ + event: { + message: "legacy error", + type: "error", + }, + executionStrategy: "react", + }), + ).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamTypedErrorOwnership.currentGuard.test.ts b/src/components/agent/chat/hooks/agentStreamTypedErrorOwnership.currentGuard.test.ts new file mode 100644 index 000000000..01be6c163 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamTypedErrorOwnership.currentGuard.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import process from "node:process"; +import { describe, expect, it } from "vitest"; + +function readHookSource(fileName: string): string { + return readFileSync( + join(process.cwd(), "src/components/agent/chat/hooks", fileName), + "utf8", + ); +} + +describe("typed error current owner boundary", () => { + it("raw runtime error aliases cannot synthesize a second terminal event", () => { + const binding = readHookSource("agentStreamTurnEventBinding.ts"); + + expect(binding).not.toContain('"runtime_error"'); + expect(binding).not.toContain('"runtime.error"'); + expect(binding).not.toContain("readRuntimeErrorMessage"); + }); + + it("handler recognizes typed error only through protocol_method", () => { + const controller = readHookSource("agentStreamTypedErrorController.ts"); + + expect(controller).toContain('params.event.protocol_method !== "error"'); + expect(controller).toContain( + 'typeof params.event.will_retry !== "boolean"', + ); + expect(controller).not.toContain("retryable"); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamUserInputSubmission.ts b/src/components/agent/chat/hooks/agentStreamUserInputSubmission.ts index 6675b3706..7627630c7 100644 --- a/src/components/agent/chat/hooks/agentStreamUserInputSubmission.ts +++ b/src/components/agent/chat/hooks/agentStreamUserInputSubmission.ts @@ -136,6 +136,7 @@ export async function submitAgentStreamUserInput( setPendingActions: env.setPendingActions, getThreadItems: env.getThreadItems, setThreadItems: env.setThreadItems, + setTodoItems: env.setTodoItems, setThreadTurns: env.setThreadTurns, setCurrentTurnId: env.setCurrentTurnId, setExecutionRuntime: env.setExecutionRuntime, diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index 55e8f0843..aea464f0b 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -422,6 +422,7 @@ export function useAgentChat(options: UseAgentChatRuntimeOptions) { setMessages: session.setMessages, getThreadItems: () => session.threadItems, setThreadItems: session.setThreadItems, + setTodoItems: session.setTodoItems, setThreadTurns: session.setThreadTurns, setCurrentTurnId: session.setCurrentTurnId, setExecutionRuntime: session.setExecutionRuntime, diff --git a/src/components/agent/chat/hooks/useAgentSession.ts b/src/components/agent/chat/hooks/useAgentSession.ts index 90800ac91..2ddb40af9 100644 --- a/src/components/agent/chat/hooks/useAgentSession.ts +++ b/src/components/agent/chat/hooks/useAgentSession.ts @@ -2643,6 +2643,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { sessionIdRef, threadRead, todoItems, + setTodoItems, workspaceId, ]); @@ -3467,6 +3468,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { currentTurnId, setCurrentTurnId, todoItems, + setTodoItems, queuedTurnCount, threadRead, getThreadIdForSubmit, diff --git a/src/components/agent/chat/hooks/useAgentStream.ts b/src/components/agent/chat/hooks/useAgentStream.ts index db386c431..10980753a 100644 --- a/src/components/agent/chat/hooks/useAgentStream.ts +++ b/src/components/agent/chat/hooks/useAgentStream.ts @@ -14,6 +14,7 @@ import type { } from "@/lib/api/agentExecutionRuntime"; import type { AgentRuntimeThreadReadModel, + AgentTodoItem, AutoContinueRequestPayload, } from "@/lib/api/agentRuntime/sessionTypes"; import { @@ -152,6 +153,7 @@ interface UseAgentStreamOptions { setMessages: Dispatch>; getThreadItems?: () => readonly AgentThreadItem[]; setThreadItems: Dispatch>; + setTodoItems: Dispatch>; setThreadTurns: Dispatch>; setCurrentTurnId: Dispatch>; setExecutionRuntime: Dispatch< @@ -198,6 +200,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { setMessages, getThreadItems, setThreadItems, + setTodoItems, setThreadTurns, setCurrentTurnId, setExecutionRuntime, @@ -271,6 +274,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { setMessages, getThreadItems, setThreadItems, + setTodoItems, setThreadTurns, setCurrentTurnId, setExecutionRuntime, @@ -310,6 +314,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { setMessages, setPendingActions, setThreadItems, + setTodoItems, setThreadTurns, setWorkspacePathMissing, warnedKeysRef, diff --git a/src/components/agent/chat/hooks/useLimeSkills.test.tsx b/src/components/agent/chat/hooks/useLimeSkills.test.tsx index 0bd2baf8b..fa3bfc88c 100644 --- a/src/components/agent/chat/hooks/useLimeSkills.test.tsx +++ b/src/components/agent/chat/hooks/useLimeSkills.test.tsx @@ -6,12 +6,19 @@ import { useLimeSkills } from "./useLimeSkills"; const mockGetLocal = vi.hoisted(() => vi.fn()); const mockGetAll = vi.hoisted(() => vi.fn()); +const mockGetRuntimeCatalog = vi.hoisted(() => vi.fn()); const mockLogAgentDebug = vi.hoisted(() => vi.fn()); +const mockSubscribeAppServerNotifications = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api/appServerEventBus", () => ({ + subscribeAppServerNotifications: mockSubscribeAppServerNotifications, +})); vi.mock("@/lib/api/skills", () => ({ skillsApi: { getLocal: mockGetLocal, getAll: mockGetAll, + getRuntimeCatalog: mockGetRuntimeCatalog, }, })); @@ -130,7 +137,8 @@ describe("useLimeSkills", () => { ).IS_REACT_ACT_ENVIRONMENT = true; vi.clearAllMocks(); vi.useFakeTimers(); - mockGetLocal.mockResolvedValue([ + mockSubscribeAppServerNotifications.mockReturnValue(vi.fn()); + mockGetRuntimeCatalog.mockResolvedValue([ { key: "local-skill", name: "本地技能", @@ -166,14 +174,14 @@ describe("useLimeSkills", () => { }); await flushEffects(); - expect(mockGetLocal).not.toHaveBeenCalled(); + expect(mockGetRuntimeCatalog).not.toHaveBeenCalled(); act(() => { vi.advanceTimersByTime(1); }); await flushEffects(); - expect(mockGetLocal).toHaveBeenCalledWith("lime"); + expect(mockGetRuntimeCatalog).toHaveBeenCalledOnce(); expect(harness.getValue().skills[0]?.key).toBe("local-skill"); expect(mockLogAgentDebug).toHaveBeenCalledWith( "TestScope", @@ -191,7 +199,7 @@ describe("useLimeSkills", () => { try { await flushEffects(); - expect(mockGetLocal).toHaveBeenCalledWith("lime"); + expect(mockGetRuntimeCatalog).toHaveBeenCalledOnce(); await act(async () => { await harness.getValue().refreshSkills(true); @@ -209,15 +217,57 @@ describe("useLimeSkills", () => { try { await flushEffects(); - expect(mockGetLocal).toHaveBeenCalledTimes(1); + expect(mockGetRuntimeCatalog).toHaveBeenCalledTimes(1); harness.rerender(); await flushEffects(); - expect(mockGetLocal).toHaveBeenCalledTimes(1); + expect(mockGetRuntimeCatalog).toHaveBeenCalledTimes(1); expect(harness.getValue().skills[0]?.key).toBe("local-skill"); } finally { harness.unmount(); } }); + + it("typed skills/changed 应重新读取本地 catalog,畸形 payload 不触发", async () => { + let onNotifications: + | ((notifications: Array<{ method: string; params: unknown }>) => void) + | undefined; + const unsubscribe = vi.fn(); + mockSubscribeAppServerNotifications.mockImplementationOnce( + (subscription) => { + onNotifications = subscription.onNotifications; + return unsubscribe; + }, + ); + const harness = mountHook({ autoLoad: false }); + + try { + expect(mockGetRuntimeCatalog).not.toHaveBeenCalled(); + act(() => { + onNotifications?.([ + { method: "skills/changed", params: { path: "/private/skill" } }, + ]); + }); + await flushEffects(); + expect(mockGetRuntimeCatalog).not.toHaveBeenCalled(); + + act(() => { + onNotifications?.([{ method: "skills/changed", params: {} }]); + }); + await flushEffects(); + expect(mockGetRuntimeCatalog).toHaveBeenCalledOnce(); + expect(mockGetLocal).not.toHaveBeenCalled(); + expect(mockGetAll).not.toHaveBeenCalled(); + expect(mockLogAgentDebug).toHaveBeenCalledWith( + "useLimeSkills", + "skillsChanged.received", + { method: "skills/changed", count: 1 }, + { consoleOnly: true }, + ); + } finally { + harness.unmount(); + } + expect(unsubscribe).toHaveBeenCalledOnce(); + }); }); diff --git a/src/components/agent/chat/hooks/useLimeSkills.ts b/src/components/agent/chat/hooks/useLimeSkills.ts index 23f608e71..778fcbcab 100644 --- a/src/components/agent/chat/hooks/useLimeSkills.ts +++ b/src/components/agent/chat/hooks/useLimeSkills.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { skillsChangedServerNotification } from "@limecloud/app-server-client"; import { logAgentDebug } from "@/lib/agentDebug"; +import { subscribeAppServerNotifications } from "@/lib/api/appServerEventBus"; import { skillsApi, type Skill } from "@/lib/api/skills"; import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; @@ -47,7 +49,7 @@ export function useLimeSkills(options: UseLimeSkillsOptions = {}) { try { const loadedSkills = includeRemote ? await skillsApi.getAll("lime") - : await skillsApi.getLocal("lime"); + : await skillsApi.getRuntimeCatalog(); if (latestRequestIdRef.current !== requestId) { return loadedSkills; @@ -108,6 +110,34 @@ export function useLimeSkills(options: UseLimeSkillsOptions = {}) { return; }, [autoLoad, deferredDelayMs, refreshSkills]); + useEffect(() => { + return subscribeAppServerNotifications({ + onNotifications: (notifications) => { + const changedCount = notifications.filter( + (notification) => + skillsChangedServerNotification(notification) !== undefined, + ).length; + if (changedCount > 0) { + logAgentDebug( + logScopeRef.current, + "skillsChanged.received", + { method: "skills/changed", count: changedCount }, + { consoleOnly: true }, + ); + void refreshSkills(false); + } + }, + onError: (error) => { + logAgentDebug( + logScopeRef.current, + "skillsChanged.subscription.error", + { error }, + { level: "warn" }, + ); + }, + }); + }, [refreshSkills]); + useEffect(() => { return () => { latestRequestIdRef.current += 1; diff --git a/src/components/agent/chat/projection/runtimeLifecycleProjection.test.ts b/src/components/agent/chat/projection/runtimeLifecycleProjection.test.ts index ae10daf91..e8ee97f25 100644 --- a/src/components/agent/chat/projection/runtimeLifecycleProjection.test.ts +++ b/src/components/agent/chat/projection/runtimeLifecycleProjection.test.ts @@ -130,6 +130,34 @@ describe("runtimeLifecycleProjection", () => { }); }); + it("typed error 不应提前生成 run.failed,终态只由 turn_failed 负责", () => { + expect( + buildRuntimeLifecycleEvents( + { + message: "provider stream disconnected", + protocol_method: "error", + type: "error", + will_retry: false, + }, + baseContext, + ), + ).toEqual([]); + + expect( + buildRuntimeLifecycleEvents( + { + message: "legacy synthetic error", + type: "error", + }, + baseContext, + )[0], + ).toMatchObject({ + phase: "failed", + sourceType: "error", + type: "run.failed", + }); + }); + it("应把 runtime_status 映射为 run.status、permission 与 team projection", () => { const events = buildRuntimeStatusEvents( { diff --git a/src/components/agent/chat/projection/runtimeLifecycleProjection.ts b/src/components/agent/chat/projection/runtimeLifecycleProjection.ts index c3600a6ac..5fac8f2eb 100644 --- a/src/components/agent/chat/projection/runtimeLifecycleProjection.ts +++ b/src/components/agent/chat/projection/runtimeLifecycleProjection.ts @@ -54,8 +54,11 @@ export function buildRuntimeLifecycleEvents( case "turn_canceled": return [buildRunCanceledEvent(event, context)]; case "turn_failed": - case "error": return [buildRunFailedEvent(event, context)]; + case "error": + return event.protocol_method === "error" + ? [] + : [buildRunFailedEvent(event, context)]; case "runtime_status": return buildRuntimeStatusEvents(event, context); case "model_change": diff --git a/src/components/agent/chat/utils/agentRuntimeErrorPresentation.test.ts b/src/components/agent/chat/utils/agentRuntimeErrorPresentation.test.ts index adaaf7e5b..ab069e723 100644 --- a/src/components/agent/chat/utils/agentRuntimeErrorPresentation.test.ts +++ b/src/components/agent/chat/utils/agentRuntimeErrorPresentation.test.ts @@ -5,6 +5,7 @@ import { MODEL_INPUT_CAPABILITY_GAP_ERROR_PREFIX } from "@/lib/model/modelCapabi import { MODEL_SELECTION_REQUIRED_ERROR_MESSAGE, resolveAgentRuntimeErrorPresentation, + resolveAgentRuntimeSubmitErrorMessage, } from "./agentRuntimeErrorPresentation"; describe("agentRuntimeErrorPresentation", () => { @@ -170,6 +171,34 @@ describe("agentRuntimeErrorPresentation", () => { }); }); + it("模型路由不可执行时应隐藏内部路由错误", async () => { + await changeLimeLocale("zh-CN"); + + expect( + resolveAgentRuntimeErrorPresentation( + "runtime model route is not executable", + ), + ).toEqual({ + displayMessage: + "当前模型通道暂时不可用,请稍后重试;如果持续失败,请检查 Provider 状态或切换到其他可用模型。", + toastMessage: + "当前模型通道暂时不可用,请稍后重试;如果持续失败,请检查 Provider 状态或切换到其他可用模型。", + }); + }); + + it("提交失败文案统一使用发送失败前缀", () => { + expect(resolveAgentRuntimeSubmitErrorMessage("network down")).toBe( + "发送失败: network down", + ); + expect( + resolveAgentRuntimeSubmitErrorMessage( + "runtime model route is not executable", + ), + ).toBe( + "当前模型通道暂时不可用,请稍后重试;如果持续失败,请检查 Provider 状态或切换到其他可用模型。", + ); + }); + it("普通错误里包含 402 字符串时不应误判为额度不足", () => { expect( resolveAgentRuntimeErrorPresentation( diff --git a/src/components/agent/chat/utils/agentRuntimeErrorPresentation.ts b/src/components/agent/chat/utils/agentRuntimeErrorPresentation.ts index 2a6af2579..da2e6011b 100644 --- a/src/components/agent/chat/utils/agentRuntimeErrorPresentation.ts +++ b/src/components/agent/chat/utils/agentRuntimeErrorPresentation.ts @@ -129,6 +129,13 @@ function isLikelyProviderChannelError(message: string): boolean { return message.includes("no available channel for model"); } +function isLikelyProviderRouteRejectedError(message: string): boolean { + return includesAny(message, [ + "runtime model route is not executable", + "model route is not executable", + ]); +} + function isLikelyProviderUnavailableError(message: string): boolean { return ( looksLikeHttpStatus(message, "503") || @@ -239,6 +246,7 @@ export function resolveAgentRuntimeErrorPresentation(errorMessage: string): { } if ( + isLikelyProviderRouteRejectedError(lowerMessage) || isLikelyProviderUnavailableError(lowerMessage) || isLikelyProviderNotFoundError(lowerMessage) ) { @@ -286,3 +294,13 @@ export function resolveAgentRuntimeErrorPresentation(errorMessage: string): { toastMessage: `响应错误: ${normalizedMessage}`, }; } + +export function resolveAgentRuntimeSubmitErrorMessage( + errorMessage: string, +): string { + const toastMessage = + resolveAgentRuntimeErrorPresentation(errorMessage).toastMessage; + return toastMessage.startsWith("响应错误:") + ? toastMessage.replace(/^响应错误:/, "发送失败:") + : toastMessage; +} diff --git a/src/components/agent/chat/utils/providerModelCompatibility.test.ts b/src/components/agent/chat/utils/providerModelCompatibility.test.ts index d6d22540e..571b48d16 100644 --- a/src/components/agent/chat/utils/providerModelCompatibility.test.ts +++ b/src/components/agent/chat/utils/providerModelCompatibility.test.ts @@ -79,6 +79,19 @@ describe("resolveProviderModelCompatibility", () => { ).toBeNull(); }); + it("Provider 显式声明的模型应保留可执行路由,不依赖能力猜测授权", () => { + expect( + getProviderModelCompatibilityIssue({ + providerType: "deepseek", + configuredProviderType: "openai", + model: "deepseek-v4-pro", + capabilityProvenance: "inferred_hint", + providerDeclaredModel: true, + enforceExecutableCapability: true, + }), + ).toBeNull(); + }); + it("应过滤不兼容模型并保留兼容模型", () => { const result = filterProviderModelsByCompatibility( { diff --git a/src/components/agent/chat/utils/providerModelCompatibility.ts b/src/components/agent/chat/utils/providerModelCompatibility.ts index b1eb010df..166a6b5db 100644 --- a/src/components/agent/chat/utils/providerModelCompatibility.ts +++ b/src/components/agent/chat/utils/providerModelCompatibility.ts @@ -5,6 +5,7 @@ export interface ProviderModelCompatibilityInput { configuredProviderType?: string | null; model: string; capabilityProvenance?: ModelCapabilityProvenance; + providerDeclaredModel?: boolean; enforceExecutableCapability?: boolean; } @@ -31,6 +32,7 @@ export function getProviderModelCompatibilityIssue({ configuredProviderType, model, capabilityProvenance, + providerDeclaredModel = false, enforceExecutableCapability = false, }: ProviderModelCompatibilityInput): ProviderModelCompatibilityIssue | null { const normalizedProviderType = normalize(providerType); @@ -50,6 +52,7 @@ export function getProviderModelCompatibilityIssue({ if ( enforceExecutableCapability && + !providerDeclaredModel && capabilityProvenance !== "canonical" && capabilityProvenance !== "provider_explicit" ) { diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx index 6f2eb3ddd..ff473ea65 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx @@ -1,6 +1,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "sonner"; import contentFactoryFixture from "@/features/plugin/testing/fixtures/content-factory-app.json"; import { buildPackageIdentity } from "@/features/plugin/install/packageIdentity"; import { normalizeManifest } from "@/features/plugin/manifest/normalizeManifest"; @@ -5607,6 +5608,9 @@ Extract it into the Agent Skills directory.`, it("@搜索 发送失败时不应误记最近使用", async () => { mockSendMessage.mockRejectedValueOnce(new Error("network down")); + const toastErrorSpy = vi + .spyOn(toast, "error") + .mockImplementation(() => "test-toast"); const harness = mountHook({ input: "@搜索 关键词:AI Agent 融资 站点:36Kr 时间:近30天 深度:深度 重点:融资额与产品发布 输出:要点", @@ -5624,10 +5628,12 @@ Extract it into the Agent Skills directory.`, expect(started).toBe(false); }); + expect(toastErrorSpy).toHaveBeenCalledWith("发送失败: network down"); expect(listServiceSkillUsage()).toEqual([]); expect(listMentionEntryUsage()).toEqual([]); } finally { harness.unmount(); + toastErrorSpy.mockRestore(); } }); diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts index 85d17751a..42adeae30 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts @@ -55,6 +55,7 @@ import { parseUrlParseWorkbenchCommand, } from "../utils/urlParseWorkbenchCommand"; import { parseVideoWorkbenchCommand } from "../utils/videoWorkbenchCommand"; +import { resolveAgentRuntimeSubmitErrorMessage } from "../utils/agentRuntimeErrorPresentation"; import { parseVoiceWorkbenchCommand } from "../utils/voiceWorkbenchCommand"; import { parseWritingWorkbenchCommand } from "../utils/writingWorkbenchCommand"; import { parseWebpageWorkbenchCommand } from "../utils/webpageWorkbenchCommand"; @@ -3132,7 +3133,7 @@ export function useWorkspaceSendActions({ error instanceof Error ? error.message : String(error); sendOptions?.observer?.onError?.(errorMessage); console.error("[AgentChat] 发送消息失败:", error); - toast.error(`发送失败: ${errorMessage}`); + toast.error(resolveAgentRuntimeSubmitErrorMessage(errorMessage)); setInput(sourceText); return false; } finally { diff --git a/src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx b/src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx index 8fd1fc5f7..d2414ba7d 100644 --- a/src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx +++ b/src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx @@ -234,6 +234,21 @@ async function submitCustomProviderDraft({ apiHost: string; apiKey?: string; model: string; +}) { + await fillCustomProviderDraft({ name, apiHost, apiKey, model }); + await clickByTestId("model-activate-button", 3); +} + +async function fillCustomProviderDraft({ + name, + apiHost, + apiKey = "sk-test", + model, +}: { + name: string; + apiHost: string; + apiKey?: string; + model: string; }) { await act(async () => { setInputValue( @@ -251,7 +266,6 @@ async function submitCustomProviderDraft({ setInputValue(findByTestId("model-draft-input"), model); }); await clickByTestId("model-draft-add-button"); - await clickByTestId("model-activate-button", 3); } function collectReadableText(node: Node): string { @@ -928,6 +942,38 @@ describe("ApiKeyProviderSection 模型管理布局", () => { "配置已保存,但连接测试未通过:模型无权限", ); expect(maybeByTestId(container, "model-add-configure")).not.toBeNull(); + expect( + findByTestId("model-add-cancel-button").textContent, + ).toContain("完成添加"); + + await clickByTestId("model-add-cancel-button"); + + expect(hookState.selectProvider).toHaveBeenCalledWith("custom-1"); + expect(maybeByTestId(container, "model-add-configure")).toBeNull(); + }); + + it("未保存的自定义供应商退出动作应明确显示取消且不伪装成完成", async () => { + const hookState = createHookState(); + const container = renderSection(); + + await openCustomProviderForm(); + await fillCustomProviderDraft({ + name: "Unsaved API", + apiHost: "https://api.example.com/v1", + model: "unsaved-model", + }); + + const cancelButton = findByTestId( + "model-add-cancel-button", + ); + expect(cancelButton.textContent).toContain("取消"); + expect(cancelButton.textContent).not.toContain("完成添加"); + + await clickByTestId("model-add-cancel-button"); + + expect(maybeByTestId(container, "model-add-configure")).toBeNull(); + expect(hookState.addCustomProvider).not.toHaveBeenCalled(); + expect(hookState.updateProvider).not.toHaveBeenCalled(); }); it("添加流程连接超时时不应向用户暴露 Desktop Host 内部命令", async () => { diff --git a/src/components/api-key-provider/ModelAddPanel.tsx b/src/components/api-key-provider/ModelAddPanel.tsx index a4232f0b1..afbaca1f9 100644 --- a/src/components/api-key-provider/ModelAddPanel.tsx +++ b/src/components/api-key-provider/ModelAddPanel.tsx @@ -57,6 +57,7 @@ import { ServerCog, SlidersHorizontal, Sparkles, + X, Zap, } from "lucide-react"; import { @@ -1085,6 +1086,9 @@ export const ModelAddPanel: React.FC = ({ ? (existingProviderById.get(template.systemProviderId) ?? null) : null; }, [draftProviderId, existingProviderById, template.systemProviderId]); + const completableProviderId = + draftProviderId ?? configuredProvider?.id ?? null; + const hasPersistedProvider = Boolean(completableProviderId); const modelFetchProfile = useMemo( () => ({ @@ -1243,7 +1247,10 @@ export const ModelAddPanel: React.FC = ({ api_host: request.api_host, enabled: true, prompt_cache_mode: request.prompt_cache_mode, - models: reconcileProviderModels(existingProvider?.models, state.models), + models: reconcileProviderModels( + existingProvider?.models, + state.models, + ), }); } else { const created = await onAddProvider(request); @@ -2297,12 +2304,24 @@ export const ModelAddPanel: React.FC = ({
); diff --git a/src/components/app-sidebar/AppUpdateEntry.tsx b/src/components/app-sidebar/AppUpdateEntry.tsx index e136442e6..8672a6b84 100644 --- a/src/components/app-sidebar/AppUpdateEntry.tsx +++ b/src/components/app-sidebar/AppUpdateEntry.tsx @@ -21,8 +21,17 @@ interface AppUpdateEntryProps { function shouldShowInstallSession( session: UpdateInstallSession | null, ): boolean { + if (!session) { + return false; + } + + if (session.stage === "installing" || session.stage === "restarting") { + return true; + } + return Boolean( - session && session.stage !== "idle" && session.stage !== "up_to_date", + session.latestVersion && + (session.stage === "completed" || session.stage === "failed"), ); } diff --git a/src/components/input-kit/ModelSelector.test.tsx b/src/components/input-kit/ModelSelector.test.tsx index 1aee62ce4..6c6765faf 100644 --- a/src/components/input-kit/ModelSelector.test.tsx +++ b/src/components/input-kit/ModelSelector.test.tsx @@ -170,6 +170,16 @@ describe("ModelSelector", () => { expect(container.textContent).not.toContain("gpt-5.5"); }); + it("已保存的 Provider 被禁用后应 fail closed,不继续展示旧模型", () => { + const { container } = renderModelSelector({ + providerType: "disabled-provider", + model: "disabled-model", + }); + + expect(container.textContent).toContain("当前已选供应商暂不可用"); + expect(container.textContent).not.toContain("disabled-model"); + }); + it("后端回填原始 providerId 时,应解析到真实受管 Provider 读取模型", () => { mockUseConfiguredProviders.mockReturnValue({ providers: [ @@ -682,6 +692,46 @@ describe("ModelSelector", () => { expect(pageText).not.toContain("暂无可用模型"); }); + it("应展示 Provider 显式添加但尚无权威能力快照的模型", () => { + mockUseConfiguredProviders.mockReturnValue({ + providers: [ + { + key: "deepseek", + label: "DeepSeek", + registryId: "deepseek", + type: "openai", + providerId: "deepseek", + apiHost: "https://api.deepseek.com", + models: ["deepseek-v4-pro"], + }, + ], + loading: false, + }); + mockUseProviderModels.mockReturnValue({ + modelIds: ["deepseek-v4-pro"], + models: [ + createModelMetadata("deepseek-v4-pro", { + capability_provenance: "inferred_hint", + provider_id: "deepseek", + provider_name: "DeepSeek", + }), + ], + loading: false, + error: null, + }); + + const { container } = renderModelSelector({ + providerType: "deepseek", + model: "deepseek-v4-pro", + }); + + clickModelSelectorTrigger(container); + + const pageText = getBodyText(); + expect(pageText).toContain("deepseek-v4-pro"); + expect(pageText).not.toContain("暂无可用模型"); + }); + it("展开后应显示模型的思考与多模态能力标签", () => { mockUseProviderModels.mockReturnValue({ modelIds: ["gpt-5.3-codex", "text-only-chat"], diff --git a/src/components/input-kit/ModelSelector.tsx b/src/components/input-kit/ModelSelector.tsx index 2858da539..9f5b99006 100644 --- a/src/components/input-kit/ModelSelector.tsx +++ b/src/components/input-kit/ModelSelector.tsx @@ -123,6 +123,20 @@ function hasProviderDeclaredModel(provider: ConfiguredProvider): boolean { return Boolean(resolveInitialProviderModel(provider)); } +function isProviderDeclaredModel( + provider: ConfiguredProvider | null | undefined, + modelId: string, +): boolean { + const normalizedModelId = modelId.trim().toLowerCase(); + return Boolean( + normalizedModelId && + provider?.models?.some( + (declaredModelId) => + declaredModelId.trim().toLowerCase() === normalizedModelId, + ), + ); +} + function resolveReasoningEffortOptions( model: EnhancedModelMetadata | null | undefined, ): ModelReasoningEffortPreset[] { @@ -394,6 +408,10 @@ export const ModelSelector: React.FC = ({ configuredProviderType: selectedProvider?.type, model: item.id, capabilityProvenance: item.capability_provenance, + providerDeclaredModel: isProviderDeclaredModel( + selectedProvider, + item.id, + ), enforceExecutableCapability: true, }); @@ -411,7 +429,7 @@ export const ModelSelector: React.FC = ({ }, ]; }), - [providerType, selectedProvider?.type, visibleModels], + [providerType, selectedProvider, visibleModels], ); const currentModels = useMemo( @@ -599,6 +617,12 @@ export const ModelSelector: React.FC = ({ const defaultHubProviderLabel = resolveOemLimeHubProviderName( resolveOemCloudRuntimeContext(), ); + const selectedProviderUnavailable = Boolean( + providerType.trim() && + shouldLoadProviders && + !providersLoading && + !selectedProvider, + ); const compactProviderType = selectedProvider?.key || providerType || "lime-hub"; const fallbackProviderLabel = @@ -620,20 +644,26 @@ export const ModelSelector: React.FC = ({ emptyStateTitle ?? t("common.modelSelector.noProvider.title"); const resolvedEmptyStateDescription = emptyStateDescription ?? t("common.modelSelector.noProvider.description"); - const selectedProviderLabel = showPlaceholderSelection + const selectedProviderLabel = selectedProviderUnavailable + ? t("common.modelSelector.provider.selectedUnavailable") + : showPlaceholderSelection ? resolvedPlaceholderLabel : selectedProvider?.label || (allowAutoProvider && !providerType.trim() ? resolvedAutoProviderLabel : fallbackProviderLabel); - const compactProviderLabel = showPlaceholderSelection + const compactProviderLabel = selectedProviderUnavailable + ? t("common.modelSelector.provider.selectedUnavailable") + : showPlaceholderSelection ? resolvedPlaceholderLabel : selectedProvider?.label || (allowAutoProvider && !providerType.trim() ? resolvedAutoProviderLabel : fallbackProviderLabel); const selectedModelLabel = - !showPlaceholderSelection && selectedProviderBlocksModelLoad + selectedProviderUnavailable + ? t("common.modelSelector.model.selectedUnavailable") + : !showPlaceholderSelection && selectedProviderBlocksModelLoad ? t("common.modelSelector.state.loginRequired") : showPlaceholderSelection ? resolvedPlaceholderLabel @@ -872,7 +902,8 @@ export const ModelSelector: React.FC = ({ ) : null} ) : null} - {selectedProvider && !selectedProviderVisible ? ( + {selectedProviderUnavailable || + (selectedProvider && !selectedProviderVisible) ? (
{t("common.modelSelector.provider.selectedUnavailable")}
diff --git a/src/components/settings-v2/system/about/index.test.tsx b/src/components/settings-v2/system/about/index.test.tsx index db9388254..55b2efcfe 100644 --- a/src/components/settings-v2/system/about/index.test.tsx +++ b/src/components/settings-v2/system/about/index.test.tsx @@ -196,12 +196,43 @@ describe("AboutSection", () => { expect(text).toContain("Copyright © 2026 Lime"); expect(text).toContain("Update available: 1.10.1"); expect(text).toContain("Check for Updates"); - expect(text).toContain("Download Update"); + expect(text).toContain("Restart to Update"); expect(text).not.toContain("Skill package opening"); expect(text).not.toContain("可更新到 1.10.1"); expect(text).not.toContain("settings.about"); }); + it("下载完成事件应独立驱动可安装状态,不依赖检查请求返回时序", async () => { + mockCheckForUpdates.mockResolvedValue({ + current: "1.10.0", + latest: null, + hasUpdate: false, + downloadUrl: null, + releaseNotesUrl: null, + releaseNotes: null, + pubDate: null, + error: null, + }); + mockGetUpdateInstallSession.mockResolvedValue( + createInstallSession({ + stage: "completed", + percent: 1, + message: "ready", + completedAt: 3, + isActive: false, + }), + ); + + const container = renderComponent(); + await waitForLoad(); + + expect(container.textContent).toContain("Update available: 1.10.1"); + expect(container.textContent).toContain("Ready to install"); + expect(findButton(container, "Restart to Update")).toBeInstanceOf( + HTMLButtonElement, + ); + }); + it("应移除关于页里的营销与能力说明噪音", async () => { const container = renderComponent(); await waitForLoad(); @@ -243,7 +274,7 @@ describe("AboutSection", () => { expect(mockCheckForUpdates).toHaveBeenNthCalledWith(2); await act(async () => { - findButton(container, "Download Update").click(); + findButton(container, "Restart to Update").click(); await waitForLoad(); }); @@ -300,7 +331,7 @@ describe("AboutSection", () => { await waitForLoad(); await act(async () => { - findButton(container, "Download Update").click(); + findButton(container, "Restart to Update").click(); await waitForLoad(); }); diff --git a/src/components/settings-v2/system/about/index.tsx b/src/components/settings-v2/system/about/index.tsx index 835f3462c..563de0540 100644 --- a/src/components/settings-v2/system/about/index.tsx +++ b/src/components/settings-v2/system/about/index.tsx @@ -65,6 +65,13 @@ export function AboutSection() { installSession !== null && installSession.stage !== "idle"; const installFailed = installStartFailed || installSession?.stage === "failed"; + const confirmedSessionUpdate = Boolean( + installSession?.stage === "completed" && installSession.latestVersion, + ); + const updateAvailable = versionInfo.hasUpdate || confirmedSessionUpdate; + const latestAvailableVersion = confirmedSessionUpdate + ? installSession?.latestVersion + : versionInfo.latest; const installStatusLabel = useMemo(() => { if (!installSession) { return ""; @@ -96,7 +103,9 @@ export function AboutSection() { }, [installProgressPercent, installSession, t]); const installActionLabel = useMemo(() => { if (!installSession) { - return t("settings.about.action.download"); + return versionInfo.hasUpdate + ? t("settings.about.action.restartToUpdate") + : t("settings.about.action.download"); } switch (installSession.stage) { @@ -110,10 +119,12 @@ export function AboutSection() { return t("settings.about.action.restarting"); case "failed": return t("settings.about.action.retryDownload"); + case "completed": + return t("settings.about.action.restartToUpdate"); default: return t("settings.about.action.download"); } - }, [installSession, t]); + }, [installSession, t, versionInfo.hasUpdate]); useEffect(() => { const loadCurrentVersion = async () => { @@ -232,10 +243,10 @@ export function AboutSection() { }; } - if (versionInfo.hasUpdate) { + if (updateAvailable) { return { label: t("settings.about.status.updateAvailable", { - version: versionInfo.latest ?? "", + version: latestAvailableVersion ?? "", }), className: "border-emerald-200 bg-emerald-50 text-emerald-700", }; @@ -263,9 +274,10 @@ export function AboutSection() { installFailed, installStatusLabel, installingUpdate, + latestAvailableVersion, t, + updateAvailable, versionInfo.error, - versionInfo.hasUpdate, versionInfo.latest, ]); @@ -312,7 +324,7 @@ export function AboutSection() { {t("settings.about.action.check")} - {versionInfo.hasUpdate ? ( + {updateAvailable ? ( <> + {!failed && !upToDate ? ( + + ) : null} - + {!upToDate ? ( + + ) : null} )}