From 19e8996c930f5133367de59a6f09e49af5e610b4 Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 15 Aug 2026 04:57:27 +0800 Subject: [PATCH] refactor(cli): use direct runtime for embedded TUI Replace the in-process App Server detour with owner-scoped direct and shared runtime adapters, preserve typed restore and side-effect contracts, and remove obsolete management wiring. --- .github/workflows/ci.yml | 3 + Cargo.lock | 7 - .../agent-runtime-deployment-design.md | 178 +- .../agent-runtime-services-design.md | 25 +- .../agent-sdk-product-architecture.md | 2 +- docs/architecture/app-server-architecture.md | 160 +- docs/architecture/product-architecture.md | 65 +- docs/plans/core-decomposition-plan.md | 2 +- ...tui-app-server-decoupling-refactor-plan.md | 234 +- pnpm-lock.yaml | 3 + scripts/check-core-boundaries.test.mjs | 27 +- .../cargo-dependency-boundaries.mjs | 3 +- .../rules/source/forbidden-rules.mjs | 13 - .../rules/source/public-api-rules.mjs | 9 + .../rules/source/required-rules.mjs | 92 +- .../rules/tui-boundary-rules.mjs | 36 +- scripts/core-boundaries/self-test.mjs | 47 +- .../core-boundaries/tui-boundary-ratchet.mjs | 2 +- src/apps/cli/AGENTS.md | 41 +- src/apps/cli/Cargo.toml | 3 - src/apps/cli/src/actions.rs | 19 +- src/apps/cli/src/agent/runtime_client.rs | 1237 +++++++--- src/apps/cli/src/agent/tui_client.rs | 1882 +++++---------- src/apps/cli/src/chat_state.rs | 279 ++- src/apps/cli/src/daemon/runner.rs | 1 - src/apps/cli/src/dispatch/runner.rs | 129 +- src/apps/cli/src/dispatch/worker.rs | 3 +- src/apps/cli/src/embedded_app_server.rs | 115 - src/apps/cli/src/main.rs | 232 +- src/apps/cli/src/management.rs | 2 +- src/apps/cli/src/model_selection.rs | 5 +- src/apps/cli/src/modes/chat.rs | 151 +- src/apps/cli/src/modes/chat/account.rs | 31 +- src/apps/cli/src/modes/chat/commands.rs | 68 +- .../cli/src/modes/chat/external_sources.rs | 20 +- src/apps/cli/src/modes/chat/input.rs | 65 +- src/apps/cli/src/modes/chat/mcp.rs | 35 +- .../cli/src/modes/chat/provider_models.rs | 2 +- src/apps/cli/src/modes/chat/run.rs | 123 +- src/apps/cli/src/modes/chat/selection.rs | 11 +- src/apps/cli/src/modes/chat/sessions.rs | 42 +- src/apps/cli/src/modes/chat/tests.rs | 293 ++- .../cli/src/peer_host/commands/session.rs | 5 + src/apps/cli/src/root_handlers.rs | 2 - src/apps/cli/src/shared_runtime.rs | 662 +++++- src/apps/cli/src/shared_tui_backend.rs | 1596 ------------- src/apps/cli/src/tui_backend.rs | 992 -------- src/apps/cli/src/tui_management.rs | 257 ++ .../src/tui_management/account_worktree.rs | 332 +++ .../src/tui_management/external_sources.rs | 162 ++ src/apps/cli/src/tui_management/hooks.rs | 165 ++ src/apps/cli/src/tui_management/mcp.rs | 146 ++ .../cli/src/tui_management/model_registry.rs | 412 ++++ src/apps/cli/src/tui_management_tests.rs | 290 +++ src/apps/cli/src/ui/chat/popups.rs | 10 +- src/apps/cli/src/ui/login_form.rs | 4 +- src/apps/cli/src/ui/model_config_form.rs | 12 +- src/apps/cli/src/ui/question.rs | 14 +- src/apps/cli/src/ui/startup.rs | 36 +- .../product_assembly_cli.rs | 271 ++- src/apps/desktop/src/api/agentic_api.rs | 7 +- src/apps/desktop/src/api/tool_api.rs | 6 + .../src/runtime/session_application.rs | 16 +- .../entry/src/main/ets/model/RemoteModels.ets | 14 + .../ets/pages/actions/ConversationIntent.ets | 19 +- .../actions/ConversationIntentDispatcher.ets | 14 +- .../pages/components/ChatMessageBubble.ets | 27 +- .../ets/pages/components/ChatTimeline.ets | 10 +- .../ets/pages/components/ConversationView.ets | 11 +- .../pages/components/ConversationViewHost.ets | 6 +- .../components/ToolInteractionPanels.ets | 21 +- .../ets/pages/components/ToolStatusList.ets | 13 +- .../runtime/AppRootRuntimeComposition.ets | 5 +- .../ets/pages/state/ConversationUiModels.ets | 26 +- .../viewmodel/ConversationController.ets | 8 +- .../viewmodel/RemoteTranscriptController.ets | 10 +- .../ets/services/ChatTimelineProjector.ets | 2 + .../main/ets/services/ChatTimelineStore.ets | 51 +- .../main/ets/services/CloudAccountClient.ets | 5 +- .../src/main/ets/services/RelayHttpClient.ets | 5 +- .../ets/services/RemoteCommandFactory.ets | 9 +- .../ets/services/RemoteSessionManager.ets | 26 +- .../services/RemoteToolActionController.ets | 15 +- .../entry/src/test/LocalTestFixtures.ets | 5 +- .../test/TransportAndGeneralChatUnit.test.ets | 162 +- .../mobile/harmonyos/tools/fake-relay.mjs | 19 + src/apps/server/src/app_server.rs | 1 + src/apps/server/src/routes/websocket.rs | 2 +- .../agent-runtime-ipc/src/operation.rs | 3 + .../agent-runtime-ipc/src/protocol.rs | 2 +- .../adapters/agent-runtime-ipc/src/server.rs | 74 +- .../src/tests/protocol_contracts.rs | 14 +- .../src/tests/shared_controller.rs | 289 ++- .../assembly/core/src/agentic/agents/mod.rs | 2 +- .../core/src/agentic/agents/registry/query.rs | 34 +- .../core/src/agentic/agents/registry/tests.rs | 48 + .../core/src/agentic/agents/registry/types.rs | 8 + .../src/agentic/coordination/coordinator.rs | 152 +- .../implementations/ask_user_question_tool.rs | 179 +- .../implementations/skills/mode_overrides.rs | 54 + .../assembly/core/src/external_sources.rs | 143 ++ src/crates/assembly/core/src/native_hooks.rs | 193 ++ .../assembly/core/src/product_runtime.rs | 31 +- .../assembly/core/src/service/config/mod.rs | 2 + .../src/service/config/model_management.rs | 314 +++ .../core/src/service/config/service.rs | 73 + .../core/src/service/mcp/management.rs | 549 +++++ .../core/src/service/mcp/management_tests.rs | 35 + .../assembly/core/src/service/mcp/mod.rs | 10 + .../service/remote_connect/account_runtime.rs | 221 ++ .../remote_connect/bot/command_router.rs | 151 +- .../service/remote_connect/remote_server.rs | 19 +- .../core/src/service_agent_runtime.rs | 81 +- src/crates/contracts/events/src/agentic.rs | 38 +- .../contracts/runtime-ports/src/agent_api.rs | 18 + .../agent-runtime/examples/sdk_minimal.rs | 2 +- .../agent-runtime/src/event_queue.rs | 264 +- .../execution/agent-runtime/src/runtime.rs | 951 +++++++- src/crates/execution/agent-runtime/src/sdk.rs | 20 +- .../agent-runtime/src/user_questions.rs | 422 +++- .../interaction_response_contracts.rs | 12 + .../agent_session_contracts/sdk_smoke.rs | 47 +- .../interfaces/acp/src/runtime/events.rs | 40 + .../interfaces/app-server-protocol/src/lib.rs | 11 +- .../app-server-protocol/src/schemas/agent.rs | 3 + .../src/schemas/session.rs | 50 +- src/crates/interfaces/app-server/AGENTS.md | 11 + src/crates/interfaces/app-server/Cargo.toml | 6 +- src/crates/interfaces/app-server/src/agent.rs | 32 +- src/crates/interfaces/app-server/src/lib.rs | 13 +- .../interfaces/app-server/src/management.rs | 218 +- .../app-server/src/management/service.rs | 2115 ----------------- .../app-server/src/management/worktree.rs | 127 - .../interfaces/app-server/src/server.rs | 243 +- .../app-server/src/server/event_forwarder.rs | 77 +- .../app-server/src/server/handlers/account.rs | 65 +- .../app-server/src/server/handlers/agent.rs | 15 +- .../app-server/src/server/handlers/app.rs | 288 ++- .../src/server/handlers/capability.rs | 234 +- .../src/server/handlers/external_source.rs | 40 +- .../app-server/src/server/handlers/hook.rs | 45 +- .../app-server/src/server/handlers/mcp.rs | 52 +- .../app-server/src/server/handlers/model.rs | 56 +- .../app-server/src/server/handlers/session.rs | 422 +++- .../app-server/src/server/handlers/skill.rs | 24 +- .../src/server/handlers/subagent.rs | 24 +- .../src/server/handlers/worktree.rs | 31 +- .../app-server/tests/agent_kernel.rs | 884 ++++++- src/crates/interfaces/sdk-host/src/host.rs | 12 + .../sdk-host/tests/host_lifecycle.rs | 6 +- .../src/remote_connect.rs | 428 +++- .../src/remote_connect/bot/state.rs | 3 +- .../tests/remote_connect_contracts.rs | 770 +++++- src/mobile-web/AGENTS.md | 1 + src/mobile-web/package.json | 2 + src/mobile-web/src/pages/ChatPage.tsx | 63 +- src/mobile-web/src/pages/PairingPage.tsx | 7 +- .../src/services/RemoteSessionManager.test.ts | 103 + .../src/services/RemoteSessionManager.ts | 68 +- src/web-ui/src/app/App.tsx | 2 + .../useSessionOwnerRefreshNotice.test.tsx | 94 + .../app/hooks/useSessionOwnerRefreshNotice.ts | 33 + .../features/dispatch/DispatchJobObserver.ts | 77 +- .../src/flow_chat/components/FlowToolCard.tsx | 2 + .../src/flow_chat/services/EventBatcher.ts | 13 + .../EventHandlerModule.test.ts | 146 ++ .../flow-chat-manager/EventHandlerModule.ts | 37 + .../flow-chat-manager/ToolEventModule.test.ts | 62 + .../flow-chat-manager/ToolEventModule.ts | 30 +- .../src/flow_chat/store/FlowChatStore.test.ts | 236 +- .../src/flow_chat/store/FlowChatStore.ts | 181 ++ .../tool-cards/AskUserQuestionCard.test.tsx | 44 + .../tool-cards/AskUserQuestionCard.tsx | 30 +- src/web-ui/src/flow_chat/types/flow-chat.ts | 9 + .../api/adapters/websocket-adapter.test.ts | 1127 ++++++++- .../api/adapters/websocket-adapter.ts | 697 +++++- .../api/service-api/AgentAPI.ts | 11 + .../infrastructure/api/service-api/ToolAPI.ts | 18 +- src/web-ui/src/locales/en-US/errors.json | 5 + src/web-ui/src/locales/zh-CN/errors.json | 5 + src/web-ui/src/locales/zh-TW/errors.json | 5 + .../shared/utils/agenticEventProjection.ts | 86 + 182 files changed, 16438 insertions(+), 9136 deletions(-) delete mode 100644 src/apps/cli/src/embedded_app_server.rs delete mode 100644 src/apps/cli/src/shared_tui_backend.rs delete mode 100644 src/apps/cli/src/tui_backend.rs create mode 100644 src/apps/cli/src/tui_management.rs create mode 100644 src/apps/cli/src/tui_management/account_worktree.rs create mode 100644 src/apps/cli/src/tui_management/external_sources.rs create mode 100644 src/apps/cli/src/tui_management/hooks.rs create mode 100644 src/apps/cli/src/tui_management/mcp.rs create mode 100644 src/apps/cli/src/tui_management/model_registry.rs create mode 100644 src/apps/cli/src/tui_management_tests.rs create mode 100644 src/crates/assembly/core/src/service/config/model_management.rs create mode 100644 src/crates/assembly/core/src/service/mcp/management.rs create mode 100644 src/crates/assembly/core/src/service/mcp/management_tests.rs delete mode 100644 src/crates/interfaces/app-server/src/management/service.rs delete mode 100644 src/crates/interfaces/app-server/src/management/worktree.rs create mode 100644 src/mobile-web/src/services/RemoteSessionManager.test.ts create mode 100644 src/web-ui/src/app/hooks/useSessionOwnerRefreshNotice.test.tsx create mode 100644 src/web-ui/src/app/hooks/useSessionOwnerRefreshNotice.ts create mode 100644 src/web-ui/src/shared/utils/agenticEventProjection.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5afe720c3..839e383b31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -401,6 +401,9 @@ jobs: - name: Build web UI run: pnpm run build:web + - name: Run mobile web tests + run: pnpm --dir src/mobile-web test + - name: Type-check mobile web run: pnpm --dir src/mobile-web run type-check diff --git a/Cargo.lock b/Cargo.lock index ad8171b9f8..5954a59d59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -905,20 +905,16 @@ name = "bitfun-app-server" version = "0.2.18" dependencies = [ "agent-client-protocol", - "anyhow", "async-trait", "bitfun-agent-runtime", "bitfun-app-server-client", "bitfun-app-server-protocol", "bitfun-core", - "bitfun-product-domains", "bitfun-runtime-ports", "log", "serde", "serde_json", "tokio", - "tracing", - "url", ] [[package]] @@ -977,9 +973,6 @@ dependencies = [ "bitfun-agent-runtime", "bitfun-agent-runtime-ipc", "bitfun-agent-tools", - "bitfun-app-server", - "bitfun-app-server-client", - "bitfun-app-server-protocol", "bitfun-core", "bitfun-core-types", "bitfun-events", diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index 0bacb6376c..4ee3d6d417 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -5,8 +5,8 @@ Agent Runtime 的模块职责见 [`agent-runtime-services-design.md`](agent-runtime-services-design.md),公开 SDK 见 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md),第三方 JS/TS 进程见 [`extensions/plugin-runtime-design.md`](extensions/plugin-runtime-design.md)。Rich Client 的 Embedded direct-runtime 决策、App Server 协议 -和 Shared transport 提案见 [`app-server-architecture.md`](app-server-architecture.md)。Embedded 迁移完成前,当前调用路径以本文及 -已接线代码为准;Shared transport 未通过独立评审前继续使用 v17。 +和 Shared transport 提案见 [`app-server-architecture.md`](app-server-architecture.md)。Embedded TUI 的 Phase 5 direct-runtime 已交付; +Shared transport 未通过独立评审前继续使用 v18。 ## 1. 决策与当前状态 @@ -18,14 +18,16 @@ BitFun 只有一套 Agent Runtime 行为。`Embedded` 和 `Shared` 只描述同 flowchart TB Desktop["Desktop GUI"] --> DesktopAdapter["Desktop / Tauri adapter"] Web["Web UI"] --> WebAS["loopback WebSocket App Server"] - TUI["Interactive TUI"] --> Backend["TuiBackend"] - Backend -->|"Embedded current"| EmbeddedAS["in-process App Server"] - Backend -->|"--shared"| SharedIPC["private Runtime IPC v17"] + TUI["Interactive TUI"] --> Client["CliAgentRuntimeClient"] + Client -->|"Embedded default"| Embedded["TuiRuntimePort::Embedded(AgentRuntime)"] + Client -->|"--shared"| SharedIPC["TuiRuntimePort::Shared(RuntimeIpcClient)
private Runtime IPC v18"] + TUI --> Management["TuiManagementOwners
concrete domain providers"] Other["Headless CLI · ACP · Peer Host · SDK Host"] --> Adapter["独立 first-party adapters"] DesktopAdapter --> API["Agent Runtime API / owner ports"] WebAS --> API - EmbeddedAS --> API + Embedded --> API SharedIPC --> API + Management --> Owners Adapter --> API API --> Coordinator["ConversationCoordinator"] Coordinator --> Owners["Session / Tool / Permission / MCP owners"] @@ -42,19 +44,21 @@ flowchart LR Current 图只表示已经接线的业务请求路径;下方 composition 图中的虚线只表示启动期构造与依赖注入。 -### 1.2 Approved Embedded target +### 1.2 Delivered Embedded composition ```mermaid flowchart TB - TUI["Embedded interactive TUI"] --> Composition["TuiBackend composition"] - Composition --> RuntimePort["TuiRuntimePort"] - RuntimePort --> Direct["DirectRuntimeTuiRuntime"] - Direct --> API["Agent Runtime API / owner ports"] - Composition --> Management["owner service/provider interfaces"] + TUI["Embedded interactive TUI"] --> Client["CliAgentRuntimeClient"] + Client --> RuntimePort["TuiRuntimePort::Embedded(AgentRuntime)"] + RuntimePort --> API["Agent Runtime API / owner ports"] + TUI --> Management["TuiManagementOwners"] + Management --> Providers["concrete owner providers"] API --> Owners["Session / Tool / Permission / MCP owners"] ``` -这是已批准但尚未交付的 Embedded direct-runtime 目标,属于 Phase 5;在 Host 完成迁移和验证前,Current 图中的 Embedded App Server 路径保持不变。 +这是 Phase 5 已交付的 Embedded direct-runtime 组合。`TuiRuntimePort` 是 +`CliAgentRuntimeClient` 内部的私有部署枚举,不是公开 transport 或新的 Runtime owner; +管理面由 `TuiManagementOwners` 组合具体 domain provider,不建立总括性的 management port。 ### 1.3 Optional Shared App Server proposal @@ -67,25 +71,25 @@ flowchart LR Runtime --> Storage["Workspace / Session storage"] ``` -这是 Phase 6 的待评审提案,不是当前 Shared TUI 的必经链路,也不改变 Current 图中的 private Runtime IPC v17。只有完成连接治理、安全、恢复、取消、限制、性能和回滚门槛后,才可评审是否替换 v17;评审也可以决定长期保留 v17。 +这是 Phase 6 的待评审提案,不是当前 Shared TUI 的必经链路,也不改变 Current 图中的 private Runtime IPC v18。只有完成连接治理、安全、恢复、取消、限制、性能和回滚门槛后,才可评审是否替换 v18;评审也可以决定长期保留 v18。 ### 1.4 Current implementation facts | 范围 | 当前状态 | |---|---| | Embedded Desktop GUI | 当前继续使用 Desktop 事件投影和 Tauri adapter;按实际打开的本机 workspace 延迟取得并持有 Embedded ownership,不增加后台进程;Embedded direct Runtime 是后续 Host/infrastructure 目标,不在此处宣称已接线 | -| Embedded interactive TUI | 当前仍通过同进程私有 App Server、in-memory transport、`AppServerClient` 和 `AppServerTuiBackend`;目标切换为 backend composition,由 `DirectRuntimeTuiRuntime` 直接调用 `AgentRuntime` typed API | +| Embedded interactive TUI | `CliAgentRuntimeClient -> TuiRuntimePort::Embedded(AgentRuntime)` 直接调用同进程 Runtime;CLI 不再组装 App Server client/server、in-memory transport 或旧 backend | | Embedded Headless CLI/Peer Host | 保留各自独立 Runtime adapter、展示和断流策略;不因交互式 TUI 迁移而强制使用 App Server | | ACP/SDK Host | 使用同一个 Runtime 事件入口的 session-scoped 订阅;各自协议和进程生命周期保持独立 | | Runtime ownership | Desktop、CLI、ACP、SDK Host 和现有 Server agent bootstrap 共用 Core owner;Embedded 取得共享锁,Shared TUI 取得独占锁,同一 workspace 上两种 deployment 互斥 | | Session 写入 | BitFun Runtime 的持久化 Session 由 `SessionManager` 管理;同一存储位置中的同一 Session 同时只允许一个本机进程写入,list/view 等只读操作不受影响 | | 当前 HTTP Server | 已组装 Embedded Runtime 和 `BitfunAppServer`,每个 `/ws` 连接通过 WebSocket transport 运行一条 App Server connection;当前固定 loopback、单用户且缺少连接级身份与作用域绑定,不构成远程或多用户 Server API | -| Shared local IPC | 未发布的 v17 本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI compatibility adapter;是否由 Shared App Server 替换仍待评审与等价证据 | -| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,通过 `/fork` 从完整历史或选中提示词之前创建分支,重命名当前 Session,读取 transcript,通过 **View subagents** 只读查看当前根 Session 的子会话并定向取消子会话活动 Turn,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,通过 `/compact` 或 `/summarize` 压缩当前 Session 上下文,在 Turn 空闲时通过 `/diff` 读取 Runtime 绑定工作区的只读差异,提交/取消 Turn,处理 Permission 和 UserInput;当前管理面具体接线为 `SharedTuiBackend -> concrete AppManagementService -> existing product owners`,其中 `AppManagementService` 是现有 owner 上的 adapter;按 domain 拆分 owner service/provider 是 Phase 5 的后续工作;Account/Settings Sync、Worktree 和后续 External Application V2 未由当前 Shared Host 提供,默认仍是 Embedded | +| Shared local IPC | 未发布的 v18 本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI compatibility adapter;是否由 Shared App Server 替换仍待评审与等价证据 | +| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 通过 `CliAgentRuntimeClient -> TuiRuntimePort::Shared(RuntimeIpcClient) -> Runtime IPC v18` 使用 Shared Runtime;Runtime 用例和事件保持 v18 的 controller、limits、取消和 unknown-outcome 合同。管理面不进入 v18,而由 `TuiManagementOwners` 组合具体 provider;MCP 进程属于 Shared Runtime Host,当前 Shared TUI 的 MCP 管理返回 typed unsupported,并要求退出 Shared clients、在 Embedded 模式管理后重启 Shared Runtime。其他 provider 按 deployment/Remote scope 返回真实 capability 或 typed unsupported | | Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、通用 Controller transfer 和 Session archive 同样不在当前协议中 | -因此当前交付的是 Embedded TUI App Server 与一条窄的、显式启用的 Shared TUI compatibility deployment; -下一步将先把 Embedded TUI 切换为 direct Runtime adapter,不把它扩展成通用本机 Server。 +因此当前交付的是 Embedded TUI direct Runtime 与一条窄的、显式启用的 Shared TUI compatibility deployment; +两者由同一个 CLI Runtime client 隐藏部署差异,不把 TUI 扩展成通用本机 Server。 具体 `EventQueue` 仍由 Core 产品装配;当前 Shared IPC 只把 TUI 必需的强类型操作和事件映射到同一个 Runtime owner, 没有公开协议承诺。是否以 App Server Shared transport 替换并删除它,由行为等价、性能、安全和回滚证据决定。 @@ -97,7 +101,7 @@ flowchart LR | Embedded deployment | Runtime 与调用入口位于同一 Rust 进程 | 简化版 Runtime | | Shared deployment | 同一 Runtime 由一个本机进程承载,多个第一方 Client 通过私有 IPC 使用 | 新 Runtime、公开 Server 或 Agent SDK | | Embedded direct Runtime | Host 通过稳定 Rust typed facade 调用同进程 Runtime owner | 第二套 Runtime、Core singleton 直连或 App Server wire | -| Embedded App Server | 当前 TUI 使用、仅作迁移前基线的同进程 App Server 实例和 in-memory transport | Embedded 的目标默认路径、后台进程或网络 Server;Phase 5 切换后删除 | +| Embedded App Server | Phase 5 前 TUI 使用、现已删除的同进程 App Server 实例和 in-memory transport | 当前 Embedded 路径、后台进程或网络 Server | | Shared App Server | 独立本机 Host 承载、由多个已认证 Rich Client 通过受控 transport 使用的 App Server | 公网 API 或每个 Client 一个 Runtime | | Agent SDK Host | 将公开 SDK 合同映射到 Runtime API 的私有进程/adapter | CLI、Shared deployment 或 Plugin Host | | Plugin Host | 运行 Node/Bun 和第三方插件代码的受监督子进程 | Agent Runtime 或 Rust IPC client | @@ -117,14 +121,14 @@ flowchart TB Desktop["Desktop GUI"] --> DesktopAdapter["Desktop / Tauri adapter"] Web["Web UI"] --> AppServer["loopback WebSocket App Server"] - EmbeddedTUI["Embedded TUI"] --> EmbeddedCurrent["in-process App Server · current"] - EmbeddedTUI -. "target" .-> Composition["TuiBackend composition"] - Composition --> RuntimePort["TuiRuntimePort"] - RuntimePort --> DirectRuntime["DirectRuntimeTuiRuntime"] - Composition --> Management["owner service/provider interfaces"] + EmbeddedTUI["Embedded TUI"] --> Client["CliAgentRuntimeClient"] + Client --> RuntimePort["TuiRuntimePort::Embedded(AgentRuntime)"] + EmbeddedTUI --> Management["TuiManagementOwners"] + Management --> Providers["concrete owner providers"] DesktopAdapter --> API AppServer --> API - DirectRuntime --> API + RuntimePort --> API + Providers --> API SharedCompat["Shared Runtime IPC · temporary compatibility"] --> API Headless["Headless / ACP adapters"] --> API SDK["SDK Host adapter"] --> API @@ -132,8 +136,8 @@ flowchart TB ``` 当前复用的是 Runtime API、权威事实和 owner;Web 额外使用 App Server wire,Embedded TUI -当前使用 App Server、目标改为 direct Runtime adapter,Shared TUI 的共同 Runtime 行为使用 -private v17。TUI 管理面由 composition 按 domain 注入 owner service/provider,不随 Runtime +已使用 direct Runtime,Shared TUI 的共同 Runtime 行为使用 private v18。TUI 管理面由 +`TuiManagementOwners` 按 domain 注入具体 owner provider,不随 Runtime port 进入 Shared wire。各入口不复用 renderer、CLI 参数、SDK wire、远程认证或平台窗口生命周期。 任何新能力必须先进入既有 Runtime 或 owner service,再由 App Server、direct adapter 或其他独立 adapter 映射,禁止在 Embedded、Shared 或其他入口复制业务实现。 @@ -157,16 +161,16 @@ flowchart LR - Core product assembly 创建事件 source,并维持旧消费队列的排空 task;第一方产品入口不再获得第二个订阅 API。 - App Server server 从注入的 `AgentEventSource` 转发需要连接边界的 Rich Client 权威事件;这些 client 不得绕过 App Server 订阅 Core `EventQueue`。 -- Phase 5 的 Embedded direct adapter 将通过 `AgentRuntime` 提供的 typed event/Permission subscription 直接订阅同一 Runtime owner,再映射为 `TuiRuntimePort` semantic event;它不得创建第二个 Core `EventQueue` owner 或把 Runtime 内部 receiver 暴露给 TUI。 +- Phase 5 的 Embedded direct path 已通过 `AgentRuntime` 提供的 typed event/Permission subscription 直接订阅同一 Runtime owner,再由 `CliAgentRuntimeClient` 映射为 TUI semantic event;restore 前先建立订阅,随后读取同一个 owner 的 coherent state/transcript/pending UserInput snapshot。它不得创建第二个 Core `EventQueue` owner 或把 Runtime 内部 receiver 暴露给 TUI。 - Headless CLI、Peer Host、ACP 和 SDK Host 从各自独立 Runtime adapter 订阅,不能直接持有 Core-specific event source。 - `bitfun-core` 的旧 event-source/builder API 仅保留为 deprecated 源码兼容 facade;它们委托给同一个 Core owner,不形成第二套运行时或第一方调用路径。 - 各 adapter 继续拥有自己的失败投影:TUI 标记当前视图不可信,Headless CLI 返回非成功终态,Peer Host 中断其拥有的 turns,ACP 取消 turn 并返回协议错误,SDK Host 终结 Query 并提供 `RestartHost` recovery。 - 当前 App Server 为每条 connection/stream 发送单调 sequence 和 connection-local cursor;`app/syncEvents` 返回当前连接的 cursor - 与 pending Permission snapshot,`session/sync` 恢复 Session state、transcript、workspace binding 和 pending Permission。它没有跨连接 + 与 pending Permission snapshot,v4 `session/sync` 从同一次权威 restore snapshot 恢复 Session state、transcript、workspace binding、pending Permission 和 pending UserInput。Rich Client 用 Session、active Turn 与 tool id 精确投影 pending UserInput,不从历史 `Started` 事件推断回答 channel 已就绪。Shared Runtime IPC Server 在执行 restore 前预订阅该 Session,使 snapshot 捕获期间产生的事件先进入连接 buffer。它没有跨连接 持久化 replay/resume:重连后的旧 cursor 不能继续消费,client 必须重新 initialize 并执行权威 sync。 -- Shared Runtime IPC v17 不复用 App Server cursor。它按自己的有界队列规则处理 lag/closed:Agent 流失效后 fail closed;Permission lag +- Shared Runtime IPC v18 不复用 App Server cursor。它按自己的有界队列规则处理 lag/closed:Agent 流失效后 fail closed;Permission lag 尝试从 Runtime 的 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。任何路径都不能把流失效伪装成透明恢复。 -- 当前旧 Embedded App Server 链路位于 Embedded 进程;direct-runtime 目标会删除 private in-memory transport、App Server task/thread 和其 JSON-RPC 编解码,不增加 SDK Host、跨进程 IPC 或后台进程依赖。 +- 旧 Embedded App Server 链路已经删除;当前 direct-runtime 不创建 private in-memory transport、App Server task/thread 或其 JSON-RPC 编解码,也不增加 SDK Host、跨进程 IPC 或后台进程依赖。 ## 4. Process View · Level 1 @@ -277,11 +281,11 @@ sequenceDiagram end ``` -当前私有协议(v17)只覆盖 TUI 已有用户旅程需要的窄操作: +当前私有协议(v18)只覆盖 TUI 已有用户旅程需要的窄操作: | 已支持 | 明确不支持 | |---|---| -| Health、只读 workspace-scoped main Agent 摘要、Session list/create、原子 restore(含 transcript 与 pending Permission)、删除未被控制的空闲 Session、当前 Session fork(含 transcript)、rename、Agent mode/model update、声明式上下文 reload、根 Session lineage 查询与后代 transcript 读取 | Session archive、跨 workspace attach、transcript 分页、模型目录/默认值和完整 Agent/Subagent 管理 | +| Health、只读 workspace-scoped main Agent 摘要、Session list/create、coherent restore(含 state、transcript、pending Permission/UserInput)、删除未被控制的空闲 Session、当前 Session fork(含 transcript)、rename、Agent mode/model update、声明式上下文 reload、根 Session lineage 查询与后代 transcript 读取 | Session archive、跨 workspace attach、transcript 分页、模型目录/默认值和完整 Agent/Subagent 管理 | | Turn submit/cancel、当前 Session 手动 context compaction、lineage 成员校验后的单个后代执行子树取消 | replay、cursor、resume event stream、独立的根级批量后代取消 API | | pending/respond Permission、submit UserInput answers、只读 workspace diff | observer、通用 controller transfer、多 Session multiplex | | 连接断开清理、Session-filtered events | detach/observer/通用 controller transfer、SDK callbacks、GUI/Remote/Peer/ACP/Headless wire | @@ -305,13 +309,23 @@ sequenceDiagram - v15 为后代 transcript 读取增加 `required_settled_turn_ids` 一致性前置条件:Runtime 必须确认这些 Turn 已由 owner 持久化为终态,否则返回 `outcome_unknown`,由 TUI 在同一绝对期限内退避重试;TUI 只保留事件投影和该读屏障,不合并或重写权威 transcript。后代取消同时携带用户实际看到的 `expected_active_turn_id`,并在 owner 锁内拒绝已经切换的 Turn,避免迟到操作取消后续执行。lineage 查询和 transcript 读取是每连接至多一个的可抢占推测读取;更新的请求会取消旧读取,使后代取消和 Session 切换不会排在慢 transcript I/O 之后。该行为不放宽 controller 校验,不引入 observer 或通用多路复用。 - v16 增加只读、workspace-scoped main Agent 摘要,用于 Shared TUI 与 Runtime host 的 selector 投影一致。启动页以 Runtime 启动工作区查询且不取得 Session lease;已有 Session 由 Runtime owner 解析其执行工作区并要求当前 controller。响应只包含逻辑 ID、描述、可选固定 model ID 与 ecosystem-neutral 的 external-source 分类;发现、审批、冲突消解、generation 与执行仍由既有 Agent Registry 和 external-source owner 负责,不经 IPC 暴露安装、变更、激活、Subagent 管理或 runtime lifecycle API。 - v17 扩展原子 restore,使响应带回 Runtime Session state;增加结构化 Session usage、等待指定 Turn settlement,以及记录本地命令 - transcript turn 的 operation。它补齐当前 Shared TUI 的行为,并为 Phase 5 将冻结的 `TuiRuntimePort` 提供 operation 基线;没有增加 + transcript turn 的 operation。它补齐当前 Shared TUI 的行为,并成为 Phase 5 已交付 `TuiRuntimePort` 的 operation 基线;没有增加 replay、observer、通用 controller transfer、多 Session multiplex 或公开 SDK 能力。 +- v18 在原子 restore 中增加 Runtime 权威的 pending UserInput,并以 post-registration + `UserInputRequested` 与 exact-registration `UserInputResolved` 事件闭合 snapshot/订阅和迟到终态 + 窗口。该字段改变 `SessionRestored` 的严格 wire shape,两个事件与 `SubmitUserAnswers` 也携带 Runtime owner 分配的 + `session_id + turn_id + registration_sequence + tool_id` 完整身份。Shared adapter 按 sequence 单调合并 + restore/live 授权事实,并在完整身份匹配后才调用 owner;旧 snapshot 不能覆盖同 tool id 的较新 ready, + 迟到的旧 resolved/terminal 也不能撤销较新的回答授权。 + 这些字段改变严格 wire shape,因而旧 v17 client 必须在握手阶段被拒绝,不能在同一版本下容忍到 + 运行期反序列化失败。Remote Connect 的 tracker、移动端和 IM Bot 复用相同完整身份;其 + `answer_question` 是第一方私有命令且当前没有版本协商,因此旧移动端不能继续仅发送 `tool_id`, + 必须与 Desktop 同步升级,身份缺失或过期时一律 fail closed。 - 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore/fork 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。fork 只接受当前 controller 的空闲 Session;无选中 Turn 时复制到最新持久化 Turn,指定 `before_turn_id` 时只复制该 Turn 之前的历史。活动 Turn 期间不能切换或 fork Session,也不能修改其名称、Agent mode 或 model;删除只作用于非当前且未被任何连接控制的 Session。 - Submit 与手动 context compaction 都使用调用方已有的 `turn_id` 标识不确定结果;若操作超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。手动 compaction 要求当前 controller 且 Session 空闲,由 Core 通过与普通对话 Turn 共用的原子准入路径创建一个可审计 maintenance Turn,并在取得所有权后读取压缩上下文:planning 阶段允许取消,atomic commit 开始后忽略晚到取消并保持 Processing 直至终态持久化完成。maintenance Turn 保留在权威 transcript 中但不进入模型上下文,live/restored payload 使用同一 compression ID 和 `applied` 事实;commit 后的持久化故障发布明确失败终态而不是遗留 Processing。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。 - Session delete/rename 和 Agent mode/model update 复用既有 Runtime 端口和校验,Runtime 对最终结果保持权威并拒绝无效目标。它们都是有副作用操作;发送前编码或 frame 上限失败表示请求未执行,连接仍可使用。rename 写入失败时恢复旧 metadata:确认恢复后返回明确失败,无法确认时返回 `outcome_unknown`。Shared Client 在请求写入后响应超时或丢失连接时也返回 `outcome_unknown` 并断开连接。两种情况都不自动重试:rename 由用户恢复 Session 并核对当前值;delete 由用户重新打开 `/sessions` 核对目标是否仍存在。模型目录以及完整 Agent/Subagent 管理仍是同版本第一方产品事实,不加入 IPC;v16 的 main Agent 摘要只是 host-owned selector 所需的最小只读投影。 - 声明式上下文 reload 只失效当前 Session 的 instructions 缓存,并按目标复用 Skill Registry 刷新;它可在活动 Turn 中执行但不改写该 Turn,generation 保护保证下一条消息重建上下文。它不引入 watcher、热替换或第二套 Runtime owner。 -- v17 保留 `update current Session model` operation 及其 controller/idle/unknown-outcome 合同,但模型目录和默认值不进入该 wire。Phase 3 移除 TUI controller 对本机产品配置 owner 的直连后,`SharedTuiBackend` 通过其持有的具体 `AppManagementService` 保留模型选择和配置;Phase 5 再拆成 Host 注入的 owner service/provider adapter。该 capability 只描述当前本机 Shared CLI adapter,不伪装成 v17 或 Remote capability。 +- v18 保留 `update current Session model` operation 及其 controller/idle/unknown-outcome 合同,但模型目录和默认值不进入该 wire。模型选择和配置由 CLI Host 的 `TuiManagementOwners::model` provider 提供,current-Session model mutation 则由 `CliAgentRuntimeClient` 的 Shared 分支提交给 v18 Runtime owner。该 capability 只描述当前本机 Shared CLI adapter,不伪装成 v18 或 Remote capability。 - Agent 事件流 lag/closed 后 fail closed;Permission lag 先从 Runtime 权威 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。路由到父 Session 的嵌套 Permission 与 AskUserQuestion 复用现有 TUI 交互,不新增第二套 UI 状态。 - Windows Shared Runtime 在初始化前把自身放入 kill-on-close Job;Unix 仅在应用内优雅退出路径中通过受管子进程组回收后代。Runtime 被 `SIGTERM`、`SIGKILL` 或崩溃直接终止后的 Unix 后代回收不在当前保证内。两者都只负责生命周期,不是安全沙箱。 - 最后一个连接离开后等待 30 秒再退出;新连接会取消 idle 退出。退出只删除自己发布的 discovery;Unix 下继任 owner 会在持有实例锁后清理同一 identity 的陈旧 socket。 @@ -334,8 +348,8 @@ flowchart LR | 路径 | 数据边界 | 性能约束 | |---|---|---| -| Embedded Rich Client(目标) | TUI backend composition 通过 `TuiRuntimePort` 和 owner service/provider adapter 调用同进程 Agent Runtime | 不初始化 App Server client/server、in-memory transport、跨进程 IPC 或后台进程;Runtime port 保持与 Shared/Web 相同的行为、错误、权限和事件语义,但不要求共享 JSON-RPC;管理 capability 单独按 provider 可用性验证 | -| Embedded App Server(当前迁移基线) | 迁移前由旧 `AppServerClient` 通过 private in-memory transport 调用同进程 App Server | 仅在 Phase 5 切换前有效;切换到 direct adapter 后删除,不保留回滚路径 | +| Embedded interactive TUI(当前) | `CliAgentRuntimeClient` 通过 `TuiRuntimePort::Embedded(AgentRuntime)` 调用同进程 Runtime,管理面通过 `TuiManagementOwners` 的具体 provider 调用 owner | 不初始化 App Server client/server、in-memory transport、跨进程 IPC 或后台进程;Runtime 行为与 Shared v18 保持同一语义合同,但不共享 wire;管理 capability 单独按 provider 可用性验证 | +| Embedded App Server(已退役基线) | Phase 5 前由旧 `AppServerClient` 通过 private in-memory transport 调用同进程 App Server | 已从 CLI 删除,不保留回滚路径 | | Embedded non-Rich Client | Headless、ACP、Peer 和 SDK Host 的独立 adapter 以 Rust 类型调用 Runtime API | 不因 Rich Client 合同承担 App Server wire;保持各自协议和生命周期 | | Shared request | Client 将 operation 编码一次并写入一个长度前缀 frame | 请求保持 128 KiB 上限;业务层只接收类型化 operation | | Shared response/event | Server 将结果或事件编码一次后写出 | 响应/事件保持 8 MiB 上限;超限使事件流明确失效,不能无界分配 | @@ -352,10 +366,11 @@ flowchart LR flowchart TB GUI["Desktop GUI"] --> DesktopAdapter["Desktop / Tauri adapter"] Web["Web UI"] --> AppServer["App Server"] - EmbeddedTUI["Embedded TUI"] --> AppServer - SharedTUI["Shared TUI"] --> SharedCompat["Runtime IPC v17 compatibility"] + EmbeddedTUI["Embedded TUI"] --> Client["CliAgentRuntimeClient · direct"] + SharedTUI["Shared TUI"] --> SharedCompat["Runtime IPC v18 compatibility"] DesktopAdapter --> API["Agent Runtime API / owner ports"] AppServer --> API["Agent Runtime API / owner ports"] + Client --> API SharedCompat --> API CLI["Headless CLI adapter"] --> API SDK["SDK Host adapter"] --> API @@ -377,32 +392,32 @@ flowchart TB ```mermaid flowchart LR - TUI["Interactive TUI · Phase 5 target"] --> Composition["TuiBackend composition"] - Composition --> Port["TuiRuntimePort"] - Port -->|"Embedded default"| Direct["DirectRuntimeTuiRuntime"] + TUI["Interactive TUI · Phase 5 delivered"] --> Client["CliAgentRuntimeClient"] + Client --> Port["TuiRuntimePort"] + Port -->|"Embedded default"| Direct["Embedded(AgentRuntime)"] Direct --> Runtime["AgentRuntime typed API"] - Composition --> Management["owner service/provider interfaces"] - Port -->|"Shared compatibility"| SharedAdapter["SharedIpcTuiRuntime"] - SharedAdapter --> IPC["adapters/agent-runtime-ipc v17"] + TUI --> Management["TuiManagementOwners"] + Management --> Providers["concrete owner providers"] + Port -->|"Shared compatibility"| SharedAdapter["Shared(RuntimeIpcClient)"] + SharedAdapter --> IPC["adapters/agent-runtime-ipc v18"] IPC --> Handler["CLI Shared handler"] Handler --> Runtime ``` -第二张图只描述已批准但尚未交付的 Phase 5 TUI composition 目标。当前生产接线仍以第一张图为准: -Embedded 使用 `AppServerTuiBackend`,Shared 使用 `SharedTuiBackend -> Runtime IPC v17`。Phase 5 中, -CLI Host 将负责命令解析、TUI 状态、错误文案、direct Runtime adapter 选择和生命周期; -backend composition 将由 `TuiRuntimePort` 承载共同 Runtime 行为,并按 domain 注入 owner service/provider -接口承载管理面。Direct adapter 负责 typed request/result/event 映射,Shared adapter 负责私有 IPC +第二张图描述 Phase 5 已交付的 TUI composition。CLI Host 负责命令解析、TUI 状态、错误文案、 +direct/shared Runtime 选择和生命周期;`CliAgentRuntimeClient` 内部的 `TuiRuntimePort` 承载共同 +Runtime 行为,`TuiManagementOwners` 按 domain 组合具体 owner provider 承载管理面。Embedded +分支负责 typed request/result/event 映射,Shared 分支负责私有 IPC 的协议与连接控制;Agent Runtime 与 owner 负责 Session 校验、持久化和权威结果。TUI 业务代码 -不根据部署形态复制业务分支,Shared 是否替换 v17 仍按独立门槛决定。这里不定义一个总括性的 +不根据部署形态复制业务分支,Shared 是否替换 v18 仍按独立门槛决定。这里不定义一个总括性的 `TuiManagementPort`。 - CLI 不依赖 SDK Host,GUI/TUI 也不依赖公开 SDK package。 -- Phase 5 完成后,交互式 TUI 的启动页和会话页将复用 app-local backend composition;Embedded Runtime 调用使用 `DirectRuntimeTuiRuntime`,Shared Runtime 调用经 `SharedIpcTuiRuntime` 映射 private Runtime IPC v17。TUI 不直接依赖 Rust Runtime SDK、Core/Service owner 或 IPC operation。 -- 管理面由 composition 按 domain 直接注入 owner-owned 的稳定 service/provider trait;原始接口若暴露内部类型,或需要 TUI DTO、权限/上下文和 capability 裁剪,才抽取薄 facade。不得创建 `TuiManagementPort` 总接口,也不得把 App Server 的 `AppManagementService` 原样迁入 CLI/TUI。 -- Web 当前独立使用自己的 loopback WebSocket App Server Host,不进入 TUI backend composition。Shared 当前只有 private Runtime IPC v17;Shared App Server 只存在于第 1.3 节的 Phase 6 candidate 图中。 +- 交互式 TUI 的启动页和会话页复用 app-local `CliAgentRuntimeClient`;Embedded Runtime 调用使用 `Embedded(AgentRuntime)`,Shared Runtime 调用经 `Shared(RuntimeIpcClient)` 映射 private Runtime IPC v18。TUI view/controller 不直接依赖 Runtime/Core 或 IPC operation。 +- 管理面由 `TuiManagementOwners` 按 domain 组合 Model、Registry、MCP、Account、Settings Sync、Worktree、Hook、External Source/Command provider。不得创建 `TuiManagementPort` 总接口,也不得把总括性的 App Server management service 迁入 CLI/TUI。 +- Web 当前独立使用自己的 loopback WebSocket App Server Host,不进入 CLI Runtime/management composition。Shared 当前只有 private Runtime IPC v18;Shared App Server 只存在于第 1.3 节的 Phase 6 candidate 图中。 - Headless CLI 和 Peer Host 使用同一 Runtime 订阅入口,但分别保留确定性退出与 Peer fanout 语义;共享订阅入口不等于共享 renderer 或产品生命周期。 -- TUI 不是 Server;Phase 5 目标中的 Embedded Host 在同进程直接调用 Runtime,是否连接 Shared deployment 是部署选择,不改变 TUI 的 renderer/键位职责或行为合同。 +- TUI 不是 Server;Embedded Host 在同进程直接调用 Runtime,是否连接 Shared deployment 是部署选择,不改变 TUI 的 renderer/键位职责或行为合同。 - Agent SDK Host 只服务外部 SDK 合同,不成为第一方 rich-client 的通用底座。 - Headless CLI 默认继续 Embedded;CI 或测试可保持独立进程和独立 workspace,不承担后台实例成本。 - Tauri 仍负责窗口和桌面能力,并逐步收窄为 product Host adapter;Embedded 产品请求可由 direct Runtime adapter 承载,需要连接边界时再使用 App Server,未来也可以管理 Shared process 的启动/重连,但不拥有 Agent Runtime 业务生命周期。 @@ -414,8 +429,8 @@ backend composition 将由 `TuiRuntimePort` 承载共同 Runtime 行为,并按 ```mermaid flowchart TB subgraph Embedded["Embedded · current"] - TUI["Interactive TUI"] --> AppServer["in-process App Server"] - AppServer --> Runtime["in-process Agent Runtime"] + TUI["Interactive TUI"] --> Client["CliAgentRuntimeClient"] + Client --> Runtime["in-process Agent Runtime"] Headless["Headless / CI"] --> Runtime end subgraph Shared["Shared · current explicit --shared"] @@ -425,40 +440,39 @@ flowchart TB SharedRuntime --> Data ``` -当前默认交互式 TUI 通过同进程 App Server,Headless CLI 和 CI 通过各自 adapter 调用同进程 +当前默认交互式 TUI 通过 `CliAgentRuntimeClient` 直接调用同进程 Runtime,Headless CLI 和 CI 通过各自 adapter 调用同进程 Runtime。只有显式 `--shared` 的交互式 TUI 进入 Shared;同一 workspace 的两种部署互斥。 多开 TUI 增加 Client 进程和有界连接,不按 Client 数量复制 Runtime、Session owner 或 Plugin Host。 -#### 5.2.2 Approved Phase 5 Embedded target +#### 5.2.2 Delivered Phase 5 Embedded path ```mermaid flowchart LR - TUI["Embedded interactive TUI"] --> Direct["Direct Runtime adapter · not yet delivered"] + TUI["Embedded interactive TUI"] --> Direct["CliAgentRuntimeClient · Embedded(AgentRuntime)"] Direct --> Runtime["in-process Agent Runtime"] Runtime --> Data["workspace + Session storage"] ``` -Phase 5 只把 Embedded interactive TUI 从当前 App Server 一次性切到 direct Runtime adapter,并删除 -旧 in-process App Server;Headless/CI 保留独立 adapter,Shared 继续使用前一张 Current 图中的 -private Runtime IPC v17。旧 App Server 不作为回滚配置保留。 +Phase 5 已把 Embedded interactive TUI 从 App Server 一次性切到 direct Runtime,并删除旧 +in-process App Server;Headless/CI 保留独立 adapter,Shared 继续使用前一张 Current 图中的 +private Runtime IPC v18。旧 App Server 不作为回滚配置保留。 -### 5.3 Scenario (+1) · Phase 5 target: rename current Session +### 5.3 Scenario (+1) · current: rename current Session -该场景将尚未交付的 Embedded direct adapter 与当前 Shared v17 行为放在同一等价目标中;它不表示 -Embedded Phase 5 已完成。 +该场景展示已交付的 Embedded direct path 与当前 Shared v18 共享同一行为合同。 ```mermaid sequenceDiagram participant U as User participant T as TUI adapter - participant B as TuiBackend - participant E as Embedded direct Runtime adapter - participant S as Shared Runtime IPC v17 adapter + participant B as CliAgentRuntimeClient + participant E as Embedded(AgentRuntime) + participant S as Shared(RuntimeIpcClient) / v18 participant R as Agent Runtime U->>T: /rename Auth refactor T->>T: trim + require idle Session - T->>B: typed TuiBackend request + T->>B: typed Runtime request alt Embedded B->>E: typed Runtime request E->>R: AgentRuntime method @@ -466,7 +480,7 @@ sequenceDiagram R-->>E: applied / failed / outcome_unknown E-->>B: mapped typed result else Shared compatibility - B->>S: Runtime IPC v17 request + B->>S: Runtime IPC v18 request S->>R: owner port call R->>R: validate ownership + persist R-->>S: applied / failed / outcome_unknown @@ -478,20 +492,20 @@ sequenceDiagram Embedded 和 Shared 最终调用同一 `AgentRuntime::rename_session`。Runtime 只有在确认旧名称已保留时才返回明确失败;持久化恢复无法确认时,两种部署都返回 `outcome_unknown`。Shared 还会在请求已发送但权威响应丢失时返回该结果并关闭连接。用户恢复 Session、检查当前名称后再决定是否重试。 -### 5.4 Scenario (+1) · Phase 5 target: delete an idle Session +### 5.4 Scenario (+1) · current: delete an idle Session ```mermaid sequenceDiagram participant U as User participant T as TUI adapter - participant B as TuiBackend - participant E as Embedded direct Runtime adapter - participant S as Shared Runtime IPC v17 adapter + participant B as CliAgentRuntimeClient + participant E as Embedded(AgentRuntime) + participant S as Shared(RuntimeIpcClient) / v18 participant R as Agent Runtime U->>T: /sessions then Ctrl+D T->>T: reject current or active target - T->>B: typed TuiBackend request + T->>B: typed Runtime request alt Embedded B->>E: typed Runtime request E->>R: AgentRuntime method @@ -499,7 +513,7 @@ sequenceDiagram R-->>E: applied / failed / outcome_unknown E-->>B: mapped typed result else Shared compatibility - B->>S: Runtime IPC v17 request + B->>S: Runtime IPC v18 request S->>R: owner port call R->>R: existing delete owner R-->>S: applied / failed / outcome_unknown @@ -509,7 +523,7 @@ sequenceDiagram T-->>U: remove only after applied ``` -Embedded 和 Shared 最终调用同一个 Agent Runtime。Shared Runtime Host 通过 v17 handler 调用 Runtime;它不是 Shared App Server。Shared Runtime Host 只在请求方没有活动 Turn、目标 Session 未被任何 Client 控制时调用 Runtime owner;`session_in_use` 和 `not_found` 保持结构化错误。TUI 复用现有单个 Session 异步任务槽位,不阻塞事件循环,也不自动重试结果不确定的删除。 +Embedded 和 Shared 最终调用同一个 Agent Runtime。Shared Runtime Host 通过 v18 handler 调用 Runtime;它不是 Shared App Server。Shared Runtime Host 只在请求方没有活动 Turn、目标 Session 未被任何 Client 控制时调用 Runtime owner;`session_in_use` 和 `not_found` 保持结构化错误。TUI 复用现有单个 Session 异步任务槽位,不阻塞事件循环,也不自动重试结果不确定的删除。 ## 6. 隔离和生命周期原则 @@ -564,11 +578,11 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 ## 9. 不变量 - 只有一套 Agent Runtime 业务实现;部署差异不能产生第二套 Session、Tool、Permission 或 MCP owner。 -- 当前入口使用第 1.1 节列出的 adapter;Embedded TUI 的下一步是从 App Server 切换到 direct Runtime adapter,Web/需要连接边界的 Rich Client 继续使用 App Server。 +- 当前入口使用第 1.1 节列出的 adapter;Embedded TUI 已切换到 direct Runtime,Web/需要连接边界的 Rich Client 继续使用 App Server。 - Client、窗口、Session 或 workspace 数量不会自动等量增加 Runtime 或 Plugin Host 进程。 - 当前 Shared Runtime IPC 是第一方 TUI 的 private compatibility transport,不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议;是否由 App Server Shared transport 替换仍待评审。 -- Shared TUI 的 Model、Skill、Subagent、MCP、External Source V1 和 Hook 管理当前由 `SharedTuiBackend` 委托其持有的具体 `AppManagementService`;Phase 5 再拆成 CLI Host 显式装配的 owner service/provider adapter。这些管理 capability 不进入 Phase 5 的 `TuiRuntimePort` 或 v17 wire。Account/Settings Sync、Worktree 和后续 External Application V2 未由当前 Shared Host 提供并返回 typed unsupported。这不扩展 v17,不改变 Shared Runtime 对 Session/chat 的权威性,也不能用于 Remote workspace 的控制端本机回退。MCP service 的进程状态和 tool registry 只属于当前 CLI 进程,不即时重配已经运行的 Shared Runtime Host;跨进程 MCP 管理需要单独的同步/restart contract。 -- 默认 GUI/TUI/Headless CLI、ACP 与 SDK Host 保持 Embedded;Phase 5 完成后,只有 Embedded TUI 产品请求切换为直接调用同进程 Runtime,并删除旧 Embedded App Server。Desktop direct Runtime 是独立的已批准迁移步骤,不由 TUI Phase 5 的完成状态代替;Headless CLI、ACP 与 SDK Host 继续使用各自 adapter。只有交互式 TUI 的显式 `--shared` 选择 Shared。互斥按 `workspace + product` 生效,不再按入口名称缩窄。 +- TUI 管理由 CLI Host 的 `TuiManagementOwners` 显式组合具体 Model、Registry、MCP、Account、Settings Sync、Worktree、Native/External Hook、External Source/Command provider。这些 capability 不进入 `TuiRuntimePort` 或 v18 wire;provider 对当前 deployment/Remote scope 发布真实 availability 或 typed unsupported,不得回落控制端本机。Embedded MCP service 属于当前 CLI 进程;Shared MCP service 属于 Shared Runtime Host,当前 controller 不启动重复 service,也不提供跨进程管理。修改 Shared MCP 配置必须退出 Shared clients、在 Embedded 模式完成后重启 Shared Runtime;未来若需在线管理,应单独设计同步/restart contract。 +- 默认 GUI/TUI/Headless CLI、ACP 与 SDK Host 保持 Embedded;Embedded TUI 已直接调用同进程 Runtime,并删除旧 Embedded App Server。Desktop direct Runtime 是独立的已批准迁移步骤,不由 TUI Phase 5 的完成状态代替;Headless CLI、ACP 与 SDK Host 继续使用各自 adapter。只有交互式 TUI 的显式 `--shared` 选择 Shared。互斥按 `workspace + product` 生效,不再按入口名称缩窄。 - Account/session cloud sync 仍使用既有 Core compatibility 边界,不属于 Shared Runtime 支持。 - Remote workspace 的文件、凭据、进程和 Runtime 位于目标执行域,禁止静默回落本机。 - 未经真实 consumer 验证的接口不进入 wire;当前 wire 只包含表中列出的 Shared TUI 操作。 diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index 671b5a611c..28af08e03f 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -68,7 +68,7 @@ Agent Runtime API 的逻辑归属与物理部署分离:相同归属模块可 私有 SDK Host 或目标机器 Runtime 中。任何 Rust 部署都只管理自己进程树内的服务与 Node/Bun Plugin Host;不能因为多个 GUI/TUI/Remote Client 连接就复制 Runtime 状态模块,或按 Client/Workspace 创建 Plugin Host。 -Rust Runtime SDK 以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前接口版本为 v6 preview: +Rust Runtime SDK 以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前接口版本为 v7 preview: 小版本更新允许增加可选 builder hook、有默认实现的端口方法或注册表查询能力,但不得向外部可用 Rust 结构体字面量(struct literal)构造的 DTO 直接增加字段,也不得改变既有端口语义、错误分类、session / turn 标识含义或 默认 feature 依赖。任何需要调用方改写现有嵌入代码的变更,必须提升接口版本并提供兼容迁移路径。 @@ -89,6 +89,27 @@ v6 将完整 Rust Runtime SDK 从空默认编译面移入 `agent-runtime` owner `deep-research` 或 `native-hook-settings`,不需要继承完整 Runtime。仓库内最小 SDK example 通过 `required-features = ["agent-runtime"]` 明确记录这一版本边界。 +v7 将声明式上下文刷新纳入可选的 `AgentContextReloadPort`。builder 未注入该端口时仍返回 +`MissingContextReloadPort`,已有 Runtime owner 的 session/turn/permission/error 语义不变;第一方 +Embedded TUI 通过这一稳定端口刷新 skills 或 instructions,不再反向依赖 Core 兼容 facade。 +同一版本还在 `AgentSessionRestoreResult` 增加 Runtime 权威的 `pending_user_inputs`,从 SDK +re-export `PendingUserInput`,并在稳定 `ToolEventData` 中增加 post-registration +`UserInputRequested` 与 exact-registration `UserInputResolved`。现有 Rust embedder 迁移时必须处理 +新增 restore 字段,并为 exhaustive event match 增加这两个 variant;前者只表示响应 channel 已注册, +不得在更早的 `Started` 事件上开放回答,后者只撤销完整注册身份匹配的授权,不能让迟到的旧终态 +关闭同一 tool id 的较新问题。 +`AgentUserAnswersRequest` 同时改为携带 Runtime owner 分配的 +`session_id + turn_id + registration_sequence + tool_id` 完整身份;`register` 返回的 sequence 必须先写入 +pending fact,随后才能发布 ready 事件。v6 embedder 不能只按 `tool_id` 提交答案,迁移到 v7 时必须保存 +ready/restore 返回的完整身份;身份不匹配会 fail closed,且不会消费当前回答 channel。 +App Server v4 的 `session/sync` 也从同一次 restore snapshot 投影 pending UserInput,供 Rich Client +在 late attach 或重连后恢复回答就绪状态;旧的 `session/restore` method 不承担这一同步合同。 +Remote Connect 的 Desktop tracker、Mobile Web、HarmonyOS 和 IM Bot 同样只把 +`UserInputRequested` 或 poll snapshot 中的完整身份视为可回答授权;`Started` 只负责展示工具调用。 +`answer_question` 命令因此携带同一组 `session_id + turn_id + registration_sequence + tool_id` 字段, +旧的仅 `tool_id` 命令会 fail closed。Remote Connect 目前没有独立版本协商,第一方移动端必须与 +Desktop 同步更新;过期客户端需要刷新或升级后才能回答,服务端不得回退到按 tool id 查找最新 channel。 + 只要外部调用方仍必须导入 `bitfun-core`、启用 `product-full`、持有具体服务管理器、读取产品命令 注册表、理解 ACP/内部端口或依赖全局可变状态,公开 SDK 发布边界就不成立。公开 SDK 的完整 术语、能力等价和版本要求以 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) 为准。 @@ -432,7 +453,7 @@ impl AgentRuntime { 该 Rust 接口是内部产品入口复用的当前形态,不是公开 Python/TypeScript SDK 的目标 API。它必须只接收 已组装的类型化部件,不负责创建 文件系统、终端、MCP、AI 客户端、Remote 提供方或产品命令。 -当前 v6 preview 接口以 message / attachment / metadata、默认标准执行目标和活动 Turn 文本 steer 作为最小输入形态;若把 +当前 v7 preview 接口以 message / attachment / metadata、默认标准执行目标、活动 Turn 文本 steer 和可选声明式上下文刷新作为最小输入形态;若把 model-round cancellation token、结构化 AgentInput 或更复杂的事件游标纳入公开 SDK, 必须分别评审 Rust Runtime SDK、SDK Host protocol 和公开 SDK API 的版本,并保留旧路径兼容。 diff --git a/docs/architecture/agent-sdk-product-architecture.md b/docs/architecture/agent-sdk-product-architecture.md index 5d72ba2275..d7eea9e0f5 100644 --- a/docs/architecture/agent-sdk-product-architecture.md +++ b/docs/architecture/agent-sdk-product-architecture.md @@ -206,7 +206,7 @@ flowchart TB 与运行第三方 JS/TS 的 Node/Bun Plugin Host 不同;三者不能共享名称或业务归属。 当前代码已经交付显式启用的 Shared TUI 最小切片,包含本机 IPC、身份、握手、Session/Turn、当前 Session 的 name/Agent mode/model、Permission/UserInput、 -ownership 和生命周期治理;Model、Skill、Subagent 和 MCP 管理暂由 Shared CLI adapter 的本地 compatibility provider 保留,不属于 v17 或公开 SDK 合同。GUI、Headless CLI、ACP、SDK Host、Server/Remote 仍没有 Shared consumer。该图中的多入口逻辑复用是 +ownership 和生命周期治理;Model、Skill、Subagent 管理暂由 Shared CLI adapter 的本地 compatibility provider 保留,不属于 v18 或公开 SDK 合同。MCP 进程属于 Shared Runtime Host;controller 不启动重复 service,Shared TUI 的 MCP 管理当前返回 typed unsupported,并要求重启 Shared Runtime 后生效。GUI、Headless CLI、ACP、SDK Host、Server/Remote 仍没有 Shared consumer。该图中的多入口逻辑复用是 当前事实,除 Shared TUI 外的跨进程 Shared deployment 仍是目标架构。 ### 4.3 各形态能做什么 diff --git a/docs/architecture/app-server-architecture.md b/docs/architecture/app-server-architecture.md index 8cf3a63e62..bc8af1dc65 100644 --- a/docs/architecture/app-server-architecture.md +++ b/docs/architecture/app-server-architecture.md @@ -1,13 +1,13 @@ # App Server 架构设计 -> 状态:Embedded direct-runtime 已确定为下一步实现方向;Shared App Server 仍是待评审提案。 +> 状态:Embedded TUI direct-runtime Phase 5 实现切换已交付;统一跨部署 fixture、性能与升级兼容证据仍是后续门槛;Shared App Server 仍是待评审提案。 > -> 基线日期:2026-08-13。 +> 基线日期:2026-08-14。 > > 本文记录 Embedded direct-runtime 决策,以及需要连接边界时的 App Server 约束和 Shared transport 提案。具体 TUI 迁移阶段、接口盘点和当前缺口见 > [`tui-app-server-decoupling-refactor-plan.md`](../plans/tui-app-server-decoupling-refactor-plan.md);Agent Runtime 的进程、所有权和实例隔离见 > [`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md);产品 owner 与分层依赖见 -> [`product-architecture.md`](product-architecture.md)。迁移完成前,当前调用路径仍以已接线代码为准;Shared transport 未通过独立评审前继续使用 v17。 +> [`product-architecture.md`](product-architecture.md)。当前调用路径以已接线代码为准;Shared transport 未通过独立评审前继续使用 v18。 ## 1. Decision and remaining proposal @@ -19,20 +19,21 @@ wire。 - Embedded Host 直接持有产品组装得到的 `AgentRuntime` 和必要的 owner/provider facade,通过 Rust 类型调用 Runtime 方法;不创建 `BitfunAppServer`、 `AppServerClient` 或 in-memory transport。 -- Embedded TUI 仍只依赖 app-local backend composition。TUI 不直接依赖 Runtime、Core - singleton、具体 Service 实现或私有 IPC operation;composition 内部由窄的 - `TuiRuntimePort` 承载跨 Embedded/Shared 的 Runtime 行为,并由 - `DirectRuntimeTuiRuntime` 完成 direct request/result/event/error 映射。 +- Embedded TUI 只依赖 app-local `CliAgentRuntimeClient`。TUI view/controller 不直接依赖 + Runtime、Core singleton、具体 Service 实现或私有 IPC operation;client 内部私有的 + `TuiRuntimePort` 以 `Embedded(AgentRuntime)` / `Shared(RuntimeIpcClient)` 隐藏部署差异。 - Model、Skill、Subagent、MCP、Account、Settings Sync、Worktree、External Source 和 Hook 等管理面不进入 `TuiRuntimePort`,也不定义总括性的 `TuiManagementPort`。它们由 - backend composition 按 domain 直接注入 owner-owned 的稳定 service/provider trait;只有 - 原始接口暴露内部类型,或需要 TUI DTO、权限/上下文和 capability 裁剪时,才增加最薄的 - owner/provider facade。`AppManagementService` 继续是 App Server wiring,不原样迁移到 CLI。 + `TuiManagementOwners` 按 domain 组合具体的 Model、Registry、MCP、Account、Settings Sync、 + Worktree、Hook、External Source/Command provider。App Server 不保留无生产消费者的管理 + service;当前通用 Server Host 未注入 management owner,因而已注册的管理方法返回 structured + unsupported,不能宣称 Web 管理已接线。未来只有真实 Host 消费者具备 Host-bound scope/auth + contract 后,才可增加按 owner 注入的窄 adapter。 - Web UI / WebSocket Host 继续使用 App Server,因为它们需要连接、transport、 认证、作用域和事件转发边界。 -- Shared TUI 继续使用 private Runtime IPC v17,直到 Shared App Server 通过 +- Shared TUI 继续使用 private Runtime IPC v18,直到 Shared App Server 通过 独立的鉴权、controller/lease、恢复、取消、背压、限制、性能和回滚门槛;本次 - Embedded 重构不自动替换或删除 v17。 + Embedded 重构不自动替换或删除 v18。 - Headless CLI/CI、ACP、Peer Host 和公开 Agent SDK 继续使用各自经评审的 adapter,不因 Rich Client 的 App Server 合同被强制改用 App Server。 - App Server 仍是协议适配层,不接管 Agent Runtime、Service 或 Product Domain @@ -48,47 +49,47 @@ Runtime owner、错误语义、事件语义和持久化规则。 | --- | --- | --- | --- | --- | | A. App Server-first Rich Clients | Desktop、Web、Embedded/Shared TUI 复用一个 wire 与 typed client | 跨 Rich Client 合同和 fixture 最集中 | Embedded 编解码与 runtime/thread 成本;Shared 必须重新交付连接治理 | 仅适用于确实需要进程/网络边界的 Host;不再作为 Embedded 默认方案 | | B. Deployment-specific product adapters(Embedded 采用) | Embedded 直接调用 Runtime API;Web/Remote/Shared 按部署选择 transport adapter,共享 owner ports | 消除同进程编解码和 server task,保持 Host 生命周期简单 | DTO、错误、恢复和行为 fixture 可能分叉;需要统一行为合同 | Direct adapter 无 owner 复制,且通过跨入口行为等价测试 | -| C. Shared Runtime use cases with separate wires(保留) | 提取稳定用例/结果,Embedded 使用 Rust adapter,Shared 保留 v17 或后继 wire,Web 使用 App Server | 业务语义集中,同时允许 deployment-specific framing、安全和性能 | 需要清晰区分 use-case DTO 与 wire DTO;client 不能假装同一协议 | Shared transport 只有通过独立门槛后才可替换 v17 | +| C. Shared Runtime use cases with separate wires(保留) | 提取稳定用例/结果,Embedded 使用 Rust adapter,Shared 保留 v18 或后继 wire,Web 使用 App Server | 业务语义集中,同时允许 deployment-specific framing、安全和性能 | 需要清晰区分 use-case DTO 与 wire DTO;client 不能假装同一协议 | Shared transport 只有通过独立门槛后才可替换 v18 | -Embedded 已选择 B/C 的受限组合,A 不再是 Embedded 默认方案。Shared 仍可选择经门槛验证的 App Server transport,或把 private v17/后继协议保留为部署专用 wire;已有 DTO 或 adapter 不能替代该评审。 +Embedded 已选择 B/C 的受限组合,A 不再是 Embedded 默认方案。Shared 仍可选择经门槛验证的 App Server transport,或把 private v18/后继协议保留为部署专用 wire;已有 DTO 或 adapter 不能替代该评审。 ### 1.2 Costs of the preferred candidate - Embedded direct adapter 需要维护 Runtime typed API 与 TUI-facing contract 的映射;必须以行为 fixture 证明它没有复制 Session、Permission、Config 或 capability 状态。 -- 迁移前继续使用现有 Embedded App Server 作为行为基线;Phase 5 切换到 direct-runtime 后删除旧路径,不保留回滚 adapter,也不能在 direct adapter 返回 unsupported 后静默回退。 -- Shared App Server 需要重新交付 v17 已有的 framing、方向性 limits、鉴权、实例身份、controller/lease、断连取消、未知结果和空闲退出,不能只复用 method/DTO。 +- Phase 5 已删除旧 Embedded App Server 路径且不保留回滚 adapter;direct path 返回 unsupported 后也不得静默回退。 +- Shared App Server 需要重新交付 v18 已有的 framing、方向性 limits、鉴权、实例身份、controller/lease、断连取消、未知结果和空闲退出,不能只复用 method/DTO。 - Desktop 迁移必须划清 controller-local capability、Tauri 生命周期和工作区 Host capability;Web/Remote 扩展还需要独立的认证、授权和多租户资源治理。 ### 1.3 当前实现状态 -目标架构与已交付能力必须分开描述: +当前交付与后续目标必须分开描述: | 范围 | 当前状态 | 目标 | | --- | --- | --- | -| Embedded TUI | 当前仍通过私有 `BitfunAppServer`、in-memory transport 和 `AppServerClient` 运行 | 切换为 backend composition,由 `TuiRuntimePort`、同进程 `AgentRuntime` 和 owner service/provider 直接提供用例 | -| Shared TUI | 仍通过私有 Runtime IPC v17 连接独立 Runtime Host | 保留 v17;是否迁入 Shared App Server 由可靠性、安全、性能和回滚证据决定 | +| Embedded TUI | `CliAgentRuntimeClient -> TuiRuntimePort::Embedded(AgentRuntime)` 直接调用同进程 Runtime;管理面由 `TuiManagementOwners` 组合具体 owner provider | 保持 direct-runtime,不恢复 CLI App Server 或总管理接口 | +| Shared TUI | 仍通过私有 Runtime IPC v18 连接独立 Runtime Host | 保留 v18;是否迁入 Shared App Server 由可靠性、安全、性能和回滚证据决定 | | Desktop GUI | 主要仍使用 Tauri command 和桌面事件投影 | Embedded 时使用 direct Runtime adapter;需要连接边界时使用 App Server,Tauri 保留平台能力 | | Web Host | 当前 Server 已组装 Embedded Runtime,WebSocket 直接承载 `BitfunAppServer`;仅适用于 loopback 单用户模式 | 补齐连接身份、作用域绑定和 Host allowlist 后才能扩展部署范围 | | App Server protocol/client | 已拆为 behavior-light crate,已有版本、能力、限制、错误和部分事件恢复类型 | 补齐 Host 注入能力、可靠性语义及跨 transport 合同测试 | | App Server server | 已注册 app、agent、session、permission、TUI/workspace、git、config 和 i18n handler | 按真实 owner 和 Host 装配收窄能力,不以已存在 DTO 代替可用性证据 | Shared TUI 继续使用 Runtime IPC 是当前 compatibility boundary。Embedded direct-runtime -迁移不改变该边界。只有 Shared App Server 通过替换门槛后,才评审迁移或删除 v17;候选 -B/C 允许将 private v17 或后继协议保留为受控的长期物理 wire。 +迁移不改变该边界。只有 Shared App Server 通过替换门槛后,才评审迁移或删除 v18;候选 +B/C 允许将 private v18 或后继协议保留为受控的长期物理 wire。 -### 1.4 Migration and replacement gates +### 1.4 Delivered invariants and replacement gates -Embedded direct-runtime 完成迁移前必须满足: +Embedded direct-runtime 当前必须持续满足: | 门槛 | 必需证据 | | --- | --- | | Typed facade 与 owner | direct adapter 只调用稳定 Runtime/owner-provider facade;不暴露内部类型,不复制 Session、Permission、Config、capability 或事件状态 | | 上下文与能力 | workspace、execution domain、permission、remote facts 和 capability 来自真实 Host/Runtime 组装;不从 UI 猜测,不静默本机回退 | | 行为与事件 | 请求结果、事件顺序、pending Permission、取消、`unsupported`、lag/closed 和 `outcome_unknown` 与既有行为合同等价 | -| 生命周期与性能 | 不创建 App Server client/server、in-memory transport 或额外 Runtime;订阅和任务可回收,并记录启动、延迟和内存对比 | -| 迁移与删除 | 同一 TUI fixture 覆盖 direct 与旧 App Server;升级/降级读取兼容,旧路径仅作为迁移基线并在切换后删除 | +| 生命周期与性能 | 不创建 App Server client/server、in-memory transport 或额外 Runtime;订阅和任务可回收。启动、延迟和内存对比仍需独立测量,不能从依赖闭包推断 wall-clock 收益 | +| 迁移与删除 | direct 与 Shared v18 的 deployment-specific focused tests 覆盖关键合同;统一跨部署 fixture 与升级/降级读取验证仍是后续证据门槛,旧路径仅留在历史基线且已删除 | -Shared App Server 只有满足以下连接治理门槛后才可替换 v17: +Shared App Server 只有满足以下连接治理门槛后才可替换 v18: | 门槛 | 必需证据 | | --- | --- | @@ -99,7 +100,7 @@ Shared App Server 只有满足以下连接治理门槛后才可替换 v17: | 取消与未知结果 | disconnect/shutdown 取消、迟到响应、operation identity、`outcome_unknown` 查询/恢复和禁止盲重试 | | Host capability | Desktop local effect 与工作区 capability 边界、provider 注入、Remote unsupported 和 Web/Remote auth 已定稿 | | 生命周期与性能 | discovery、startup、idle exit、crash cleanup、延迟、吞吐和内存有预算与测量 | -| 迁移与回滚 | 同一第一方 consumer 完成 opt-in 双栈 parity;升级/降级和 v17 rollback 可重复验证;删除条件有明确 owner 批准 | +| 迁移与回滚 | 同一第一方 consumer 完成 opt-in 双栈 parity;升级/降级和 v18 rollback 可重复验证;删除条件有明确 owner 批准 | ## 2. 问题与目标 @@ -141,7 +142,7 @@ BitFun 的目标是让 direct adapter 与连接型 App Server adapter 共享可 | Rich Client | 需要持续会话、交互事件和产品管理面的第一方 GUI/Web/TUI | Headless automation、ACP、公开 SDK | | Host | 组装 direct Runtime adapter 或 App Server、选择 transport、注入能力并管理生命周期的产品入口 | 新业务层、普通用户必须管理的 Server 产品 | | Embedded direct Runtime | 与 Rich Client Host 同进程、由 `AgentRuntime` typed API 和 owner/provider facade 提供用例的部署方式 | 第二套 Runtime、App Server wire、跨进程后台服务 | -| Embedded App Server | 迁移前 TUI 使用的同进程私有 App Server 实例和 in-memory transport | Embedded 的目标默认路径、网络 Server、共享后台进程;Phase 5 完成后删除 | +| Embedded App Server | Phase 5 前 TUI 使用、现已删除的同进程私有 App Server 实例和 in-memory transport | 当前 Embedded 路径、网络 Server、共享后台进程 | | Shared App Server | 由独立本机 Host 承载、允许多个已认证第一方 client 使用的 App Server 实例 | 公网 API、Agent SDK Host、每个 client 一个 Runtime | | Runtime owner | 持有 Session、Turn、Permission、Tool/MCP、Hook、事件和持久化事实的既有模块 | App Server handler 或 UI read model | | Host capability | 当前 Host 确实组装并允许调用的产品能力 | schema 中存在的方法全集 | @@ -161,7 +162,7 @@ flowchart LR Web --> Host TUI --> Host Host --> Route{"Deployment route"} - Route -->|"Embedded direct · approved target"| Direct["Direct Runtime adapter"] + Route -->|"Embedded direct · current TUI"| Direct["CliAgentRuntimeClient"] Route -->|"Web / candidate Shared · Phase 6"| Client["App Server Client"] Client --> Transport["Host-selected transport"] Transport --> Server["App Server"] @@ -177,8 +178,8 @@ capability/allowlist 和平台能力。App Server handler 负责 method 合同 持久化和权威状态仍由对应 owner 提交。 图中的 Web 分支是当前 loopback WebSocket App Server 路径;Shared 分支仅表示 Phase 6 -candidate,不是当前或已批准的必经链路。当前 Shared TUI 仍只使用 private Runtime IPC v17, -Web 也不经过 TUI backend composition。 +candidate,不是当前或已批准的必经链路。当前 Shared TUI 仍只使用 private Runtime IPC v18, +Web 也不经过 CLI Runtime/management composition。 ### 5.1 四层合同 @@ -189,24 +190,24 @@ Web 也不经过 TUI backend composition。 | Host 合同 | transport、可用能力、限制、身份、作用域、生命周期和 controller-local provider | 复制业务规则或权威状态 | | Owner 合同 | Runtime/Service/Product Domain 的业务事实、校验和提交 | JSON-RPC、Tauri、WebSocket、Ratatui | -行为合同是 Embedded direct-runtime 与 Shared wire 等价的核心;迁移前的 Embedded App Server -仅作为 direct-runtime 切换前的行为基线,不构成迁移后的第三条运行路径。其中,Phase 5 将引入的 -`TuiRuntimePort` 是 Shared IPC operation 集合在 TUI 侧的窄语义边界;管理 service/provider +行为合同是 Embedded direct-runtime 与 Shared wire 等价的核心;已删除的 Embedded App Server +仅是 direct-runtime 切换前的行为基线,不构成第三条运行路径。其中,Phase 5 已交付的 +`TuiRuntimePort` 是 `CliAgentRuntimeClient` 内部基于 Shared IPC operation 集合形成的窄语义边界;管理 service/provider 不是 Shared Runtime wire 的组成部分。行为合同不要求三者共享 JSON;只要 Runtime port 在断连、超时、事件落后、权限、取消和 unknown outcome 上保持明确且可验证的语义, 即可共享同一 Runtime 用例合同。管理面按各自 service/provider 的可用性单独验证。 -## 6. Phase 5 Embedded interactive TUI target +## 6. Phase 5 Embedded interactive TUI(实现切换已交付) -本节只定义交互式 TUI 的 Phase 5 目标路径。它不描述 Desktop 或 Web 的迁移完成状态; +本节定义交互式 TUI 的 Phase 5 当前路径。它不描述 Desktop 或 Web 的迁移完成状态; Desktop direct Runtime 是独立的已批准迁移步骤,尚未实施。 ```text Embedded interactive TUI -> Host adapter - -> TuiBackend composition - -> TuiRuntimePort -> DirectRuntimeTuiRuntime -> AgentRuntime typed API - -> owner-owned service/provider interfaces (management only) + -> CliAgentRuntimeClient + -> TuiRuntimePort::Embedded(AgentRuntime) -> AgentRuntime typed API + -> TuiManagementOwners -> concrete owner providers (management only) -> Runtime API / owners ``` @@ -220,20 +221,20 @@ Embedded Host 必须: 不创建第二个 Core `EventQueue` 订阅或第二份 read model 权威状态。 4. 将 Runtime/domain error 映射为 Runtime port 或对应 owner service 的 TUI error,保留 `unsupported`、取消、 `outcome_unknown` 等可观察语义。 -5. 在 Host 退出时取消并回收由 direct adapter 创建的订阅和任务,并使用与 Shared - 和 Web 路径相同的行为 fixture。 +5. 在 Host 退出时取消并回收由 direct adapter 创建的订阅和任务。当前 direct 与 Shared + 分别使用 focused behavior tests;统一跨部署 fixture 仍是后续验证门槛,不能用源码形状断言替代。 Embedded 可以省略只对跨进程多客户端有意义的机制:endpoint discovery、进程 token、 外部实例锁、多客户端 controller lease、frame 编解码和空闲后台退出。省略这些机制 不能改变请求结果、事件顺序、取消结果、权限边界或 capability 语义。 -迁移窗口内可以用现有 Embedded App Server 与 direct adapter 做行为对照,但不保留可选的 -rollback adapter,也不得在 direct adapter 返回 unsupported 后静默回退。完成 direct-runtime -行为、性能和升级兼容验证后,Phase 5 必须删除旧路径。 +Phase 5 已删除旧 Embedded App Server 和可选 rollback adapter;direct path 返回 unsupported +后不得静默回退。后续统一行为 fixture、性能测量和升级兼容验证只覆盖 current direct path 与 +Shared v18,不恢复第三条运行路径;这些证据尚未全部完成,不得写成当前交付结果。 ## 7. Shared deployment -当前 Shared deployment 由独立 Runtime Host 通过 private Runtime IPC v17 服务交互式 TUI,不运行 App Server。若后续采用 Shared App Server,则目标拓扑为一个本机 App Server Host 承载一个 Runtime owner,多个第一方 Rich Client 通过受控 Pipe、UDS 或等价私有 transport 连接: +当前 Shared deployment 由独立 Runtime Host 通过 private Runtime IPC v18 服务交互式 TUI,不运行 App Server。若后续采用 Shared App Server,则目标拓扑为一个本机 App Server Host 承载一个 Runtime owner,多个第一方 Rich Client 通过受控 Pipe、UDS 或等价私有 transport 连接: ```mermaid flowchart LR @@ -257,7 +258,7 @@ flowchart LR - 无客户端且无活动任务时的受控空闲退出。 - 副作用请求在超时或断连后的 `outcome_unknown` 结果。 -当前 Runtime IPC v17 已具有 128 KiB request、8 MiB response/event、token、实例身份、controller/lease、断连取消、有界事件流、`outcome_unknown` 和空闲退出等合同。在 App Server Shared transport 逐项获得等价测试前,该 IPC 可以作为 Shared TUI 的兼容 adapter 保留;不得先切换 transport 再以功能回退换取表面统一。 +当前 Runtime IPC v18 已具有 128 KiB request、8 MiB response/event、token、实例身份、controller/lease、断连取消、有界事件流、`outcome_unknown` 和空闲退出等合同。在 App Server Shared transport 逐项获得等价测试前,该 IPC 可以作为 Shared TUI 的兼容 adapter 保留;不得先切换 transport 再以功能回退换取表面统一。 ## 8. Desktop GUI 与 Tauri @@ -322,7 +323,9 @@ WebSocket 是 App Server 的一种 transport,不是另一套业务 API。Web H 4. transport limits 必须反映当前连接的真实限制;不能把 server 内部默认值宣传为所有 transport 的通用事实。 5. method 级 allowlist 必须是 capability 声明的子集,fallback handler 不能扩大可调用面。 -当前实现中通用 App Server 初始化声明 16 MiB frame,而 WebSocket Host 接收上限为 256 KiB,Shared Runtime IPC 又区分 128 KiB request 与 8 MiB response/event。目标合同需要表达方向和 transport 的真实限制;在扩展 schema 前,Host 至少必须返回不超过底层 transport 的有效上限。 +当前 App Server 的 Permission capability 由注入 Runtime 是否实际持有 Permission request manager 决定;缺少 owner 时初始化返回 `Unavailable`,Permission snapshot 与 command 均 fail closed,不用空结果伪装可用。 + +当前通用 App Server 默认声明 16 MiB frame;WebSocket Host 会覆盖为其实际 256 KiB 接收上限,避免客户端信任超过底层 transport 的数值。Shared Runtime IPC 仍区分 128 KiB request 与 8 MiB response/event;若后续协议需要分别表达请求/响应方向,应扩展稳定 schema,而不是再次在入口硬编码不一致上限。 ## 11. 事件、恢复与取消 @@ -336,6 +339,8 @@ WebSocket 是 App Server 的一种 transport,不是另一套业务 API。Web H - `closed`、`lagged`、`invalidated` 和 `recoverable` 的明确区分。 - snapshot/sync 或要求重新装载 Session 的恢复指令。 +当前 Permission sync 是连接内的 at-least-once 合同。Runtime owner 先把 request 放入 pending,再完成 audit 并发布 `Asked`;因此 sync snapshot 已包含某个 `request_id` 时,同一请求仍可能在返回 watermark 之后作为事件到达。App Server 必须保留这种重复并保证请求不会落入 snapshot/event 空窗,client 按 `request_id` 幂等合并。该合同不表示跨连接持久 replay 或 exactly-once delivery。 + client 落后、frame 超限或连接中断时不能假装事件完整。可恢复流从 server 确认的 cursor/snapshot 继续;不可恢复流进入 invalidated,UI 必须停止基于旧 read model 提交依赖状态的新操作,直到 resync 完成。 取消属于行为合同: @@ -388,6 +393,16 @@ Embedded direct invocation 可以依赖同进程构造身份,但仍必须传 - server wiring 可以依赖生产 handler 所需的明确 owner feature,但禁止选择 `bitfun-core/product-full`。 - 新 domain 只能增加真实 handler 所需的最窄 owner feature,并通过边界检查证明依赖方向。 - protocol DTO 不复制 Runtime 内部对象;只暴露 Rich Client 需要的稳定字段和 read model。 +- App Server protocol v4 将 post-registration `UserInputRequested` 与 exact-registration + `UserInputResolved` 纳入稳定 Agent event wire, + 并在 ready event 与 `agent/submitUserAnswers` 中传递 Runtime owner 分配的 + `session_id + turn_id + registration_sequence + tool_id` 完整身份,使所有首方 Rich Client 只在 + Runtime 注册回答 channel 后开放提交,并仅在完整身份匹配时撤销授权;身份不匹配不会消费当前 channel, + 迟到的旧终态不会关闭同 tool id 的较新问题。该 event/request 增量会破坏旧 + client 的严格反序列化,因此 `PROTOCOL_VERSION` 与 `MIN_PROTOCOL_VERSION` 同步升至 4; + connection 在成功 `app/initialize` 前不转发业务请求或事件,v2/v3 client 必须在协商阶段 + fail closed。v4 `session/sync` 使用同一次权威 restore snapshot 返回 transcript 与 pending + UserInput,使 late attach/reconnect 的 Rich Client 不依赖已经错过的 ready 事件。 - `app-server-protocol` 是 TypeScript wire schema 的唯一导出 owner;`app-server/ts` 仅保留为兼容转发。owner 对象到 wire read model 的转换留在 server adapter,不能为了 生成类型把 Core、Agent Runtime 或 Service 实现依赖下沉到 protocol。 @@ -398,34 +413,33 @@ Embedded direct invocation 可以依赖同进程构造身份,但仍必须传 - transport 实现留在 Host/adapter 边界,generic role/transport helper 保持 schema-free。 - App Server 不持有第二份 Session、Permission、Config 或 capability 权威状态。 -## 15. 迁移顺序 +## 15. 已交付迁移与后续顺序 迁移按行为闭环推进,不按 method 数量推进: - 1. **锁定 Runtime 行为合同**:按当前 Shared IPC v17 operation 集合冻结窄 - `TuiRuntimePort`,稳定 Runtime request/response/event 类型、错误、能力、取消和事件语义; - 为 direct Embedded 和 Shared 增加同一 Runtime 行为 fixture,迁移前可用旧 App Server 建立行为基线。 - 2. **拆分 TUI backend composition**:将当前单体 `TuiBackend` 拆为 `TuiRuntimePort` 与按 - domain 注入的 owner service/provider 接口;不定义总括性的 `TuiManagementPort`。管理 service - 能直接复用稳定 owner trait 的直接注入;只有需要 TUI DTO、权限/上下文或 capability 裁剪时 - 才增加薄 facade。 - 3. **迁移 Embedded TUI**:实现 `DirectRuntimeTuiRuntime`,将 Runtime 调用从 - `AppServerTuiBackend` 移出;再移除 in-memory transport、Embedded `BitfunAppServer`、 - server thread 和 TUI-facing App Server client 依赖。管理面使用 owner-owned service/provider, - 不把具体 `AppManagementService` 原样搬入 CLI/TUI。 +1. **已交付 Runtime seam**:`CliAgentRuntimeClient` 内部以私有 `TuiRuntimePort` 组合 + `Embedded(AgentRuntime)` 与 `Shared(RuntimeIpcClient)`,统一 Runtime request/result/event、 + 错误、取消和事件语义;Shared 分支继续使用 private Runtime IPC v18。 +2. **已交付管理 owner 组合**:CLI 通过 `TuiManagementOwners` 显式组合具体 domain provider; + 不定义总括性的 `TuiManagementPort`,也不把总括性的 App Server management service 搬入 CLI。 +3. **已交付 Embedded TUI direct-runtime**:已移除 CLI in-memory transport、Embedded + `BitfunAppServer`、server thread、TUI-facing App Server client 和旧 `TuiBackend` family; + 不保留 rollback adapter。 4. **迁移 Desktop GUI**:Embedded 时使用 direct Runtime adapter;Web/Shared 或需要连接治理 的场景保留 App Server。Tauri 继续承载平台能力与生命周期。 5. **补齐 Shared 语义**:把 authentication、instance identity、controller/lease、framing、 背压、断连取消、idle exit、event recovery 和 `outcome_unknown` 纳入 App Server Host/transport。 6. **评审 Shared TUI 迁移**:Shared App Server 只有通过 1.4 节门槛后才可替换 Runtime IPC - compatibility adapter;v17 是否删除由独立评审和验证证据决定。若选择 B/C, - 记录 v17 的长期 owner、版本和删除条件。 + compatibility adapter;v18 是否删除由独立评审和验证证据决定。若选择 B/C, + 记录 v18 的长期 owner、版本和删除条件。 7. **收紧 Web Host**:由 Host 注入 allowlist、作用域和真实 limits;完成安全绑定前保持 loopback 单用户限制。 -8. **删除旁路**:移除 Rich Client 的重复 Runtime 事件投影、旧 Embedded App Server route 和 - 无生产消费方的兼容代码。 +8. **继续删除旁路**:不恢复 Rich Client 的重复 Runtime 事件投影、旧 Embedded App Server + route 或无生产消费方的兼容代码。 -迁移期间不得在 App Server 返回 unsupported 后静默调用旧 Tauri/Core/IPC 路径。需要暂存旧路径时,必须由 Host 在启动时明确选择完整 adapter,且 UI 只看到一个 `TuiBackend` 或 frontend infrastructure 接口。 +任何入口不得在 App Server 或 direct owner provider 返回 unsupported 后静默调用旧 +Tauri/Core/IPC 路径。Host 必须在启动时明确选择完整 adapter;CLI UI 只看到 +`CliAgentRuntimeClient` 和具体 management provider 投影。 ## 16. 验证与完成标准 @@ -433,8 +447,8 @@ Embedded direct invocation 可以依赖同进程构造身份,但仍必须传 - protocol serialization、版本上下界、未知字段和类型化错误合同测试。 - 同一用例在 Embedded direct-runtime、Shared process transport 和 WebSocket App Server(适用时) - 的行为等价测试;迁移前旧 Embedded App Server 仅用于建立基线,不作为持续测试路径。 -- `TuiRuntimePort` coverage:当前 Shared IPC v17 的每个 Runtime operation 都有对应的 + 的行为等价测试;旧 Embedded App Server 仅存在于历史基线,不作为持续测试路径。 +- `TuiRuntimePort` coverage:当前 Shared IPC v18 的每个 Runtime operation 都有对应的 port 行为测试;管理 service/provider 则单独验证 capability、权限、unsupported 和 Remote fail-closed,不以 Runtime port parity 代替管理面验证。 - Host capability/provider/allowlist 组合测试,以及真实 transport limit 测试。 @@ -450,21 +464,21 @@ Embedded direct invocation 可以依赖同进程构造身份,但仍必须传 只有交互式 TUI 同时满足以下条件,TUI/App Server 解耦才算完成;这一定义不涵盖 Desktop 的独立 direct Runtime 迁移: -1. Embedded TUI 的产品请求和订阅经过 `TuiAgentClient` 的 backend composition;Runtime - 行为经过 `TuiRuntimePort -> DirectRuntimeTuiRuntime`,管理能力经过对应 owner service/provider, +1. Embedded TUI 的产品请求和订阅经过 `CliAgentRuntimeClient`;Runtime + 行为经过 `TuiRuntimePort::Embedded(AgentRuntime)`,管理能力经过 `TuiManagementOwners` 的具体 owner provider, TUI view/reducer 不执行 backend I/O。 2. direct adapter 只调用既有 Runtime API、owner-owned service/provider 和必要的薄 facade, 不复制 Session、Permission、Config、capability 或事件权威状态,也不定义 `TuiManagementPort`。 -3. Embedded direct-runtime 与 Shared v17 的行为合同覆盖请求结果、事件顺序、 +3. Embedded direct-runtime 与 Shared v18 的行为合同覆盖请求结果、事件顺序、 取消、权限、unsupported、断连和 unknown outcome。 4. capability、作用域和 remote facts 来自真实 Host/Runtime 组装;Remote workspace 不存在 controller-local fallback。 5. Embedded 不创建 App Server client/server、in-memory transport 或额外 Runtime 进程; direct adapter 的任务和订阅在 Host 退出时可回收。 6. App Server 仍可被 Web/Shared Host 使用,且 handler 不持有业务权威状态或复制 owner 策略。 -7. 旧 Embedded App Server 路径已删除且不再作为 rollback adapter;70 方法单体 `TuiBackend` - 不再作为稳定接口边界。 -8. Shared transport 若要替换 v17,仍需单独满足鉴权、lease、取消、背压、限制、失效和生命周期 +7. 旧 Embedded App Server 路径已删除且不再作为 rollback adapter;旧单体 `TuiBackend` + family 不再作为稳定接口边界。 +8. Shared transport 若要替换 v18,仍需单独满足鉴权、lease、取消、背压、限制、失效和生命周期 等价门槛。 9. 上述合同、行为、安全、依赖、升级兼容和跨入口测试全部通过。 @@ -492,5 +506,5 @@ Desktop 的独立 direct Runtime 迁移: 这些待决项会影响 Shared transport 的选择,不能被实现默认值或迁移进度替代。Shared 评审结论必须记录 所选 transport、拒绝其他方案的理由、门槛 owner、验证证据和回滚/删除条件。Embedded direct-runtime -迁移完成后,旧 Embedded App Server 必须删除且不保留回滚路径;WebSocket App Server 与 Shared v17 +迁移完成后,旧 Embedded App Server 必须删除且不保留回滚路径;WebSocket App Server 与 Shared v18 按各自部署合同继续有效。 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 7be8042776..6e64dc67f8 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -22,8 +22,8 @@ Headless CLI 与各产品入口的统一心智见 状态共享、隔离、容量与 Plugin Host 关系见 [`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md);Desktop GUI、Web UI 和交互式 TUI 的产品后端边界、 Embedded direct-runtime、Shared App Server 及迁移约束见 -[`app-server-architecture.md`](app-server-architecture.md)。Embedded direct-runtime 已确定为下一步实现方向;Shared App Server -仍是待评审提案。在迁移或决策门槛通过前,当前调用路径和稳定 owner 边界仍以本文及已接线代码为准。其他已批准的详细设计与本文件冲突时,以本文件为准。 +[`app-server-architecture.md`](app-server-architecture.md)。Embedded TUI direct-runtime Phase 5 已交付;Shared App Server +仍是待评审提案。当前调用路径和稳定 owner 边界以本文及已接线代码为准。其他已批准的详细设计与本文件冲突时,以本文件为准。 Cargo feature、第三方依赖 owner、测试目标和本地/CI 验证分工见 [`rust-build-dependency-boundaries.md`](rust-build-dependency-boundaries.md)。该文档补充本架构的构建视图,不改变本文定义的运行时 owner 和分层依赖方向。 @@ -309,7 +309,7 @@ flowchart LR ### 2.4 Physical View · Level 0 Physical View 展示当前生产环境中可执行单元到设备、主机和存储的映射。Desktop、CLI、ACP 和 SDK Host 使用 Embedded Runtime; -Embedded 交互式 TUI 当前仍在同一 CLI 进程内通过私有 App Server 使用 Runtime;交互式 TUI 也可以显式连接当前 Shared Runtime IPC。 +Embedded 交互式 TUI 当前在同一 CLI 进程内通过 `CliAgentRuntimeClient` 直接使用 Runtime;交互式 TUI 也可以显式连接当前 Shared Runtime IPC。 Desktop GUI 当前仍使用 Tauri adapter;独立 direct Runtime 迁移尚未实施。当前 loopback Web Server 已承载 Embedded Runtime 和 WebSocket App Server;Relay Server 不承载 Agent Runtime。 @@ -375,7 +375,7 @@ flowchart LR | Deployment unit | Main contents | |---|---| | Desktop App | Web UI、Tauri Host、embedded Agent Runtime;当前 Desktop 产品请求使用现有 Tauri adapter | -| CLI App | 交互式 TUI 当前通过 in-process App Server 使用 Embedded Runtime;Headless、Peer 保留独立 adapter;可显式使用 Shared TUI | +| CLI App | 交互式 TUI 由 `CliAgentRuntimeClient` 选择 direct Embedded Runtime 或显式 Shared Runtime IPC v18,管理面由 `TuiManagementOwners` 组合具体 provider;Headless、Peer 保留独立 adapter | | Shared Runtime | 私有本机 IPC;当前只有交互式 TUI consumer;是否迁入 Shared App Server transport 仍待评审与等价证据 | | ACP | Embedded Agent Runtime、ACP 协议生命周期 | | SDK Host | 私有跨进程 adapter;公开 SDK 产品尚未交付 | @@ -423,18 +423,18 @@ flowchart TB ## 3. 接口边界 BitFun 只保留四个稳定业务接口边界;工具、事件和权限作为归属子接口被复用,不在插件层重复定义。App Server -是 Agent Runtime API 和其他 owner 接口面向当前 Web/Embedded TUI,以及未来确实需要连接边界的 Rich Client 的版本化 wire adapter,不新增第五个 -业务 owner 或能力分类。Embedded TUI 的目标改为 direct Runtime adapter;Shared 是否使用 App Server 由 4.3 节所述评审决定。本文使用 +是 Agent Runtime API 和其他 owner 接口面向当前 Web,以及未来确实需要连接边界的 Rich Client 的版本化 wire adapter,不新增第五个 +业务 owner 或能力分类。Embedded TUI 已改为 direct Runtime;Shared 是否使用 App Server 由 4.3 节所述评审决定。本文使用 “接口”描述可被调用或依赖的能力面;只有描述跨进程消息封装、结构化 schema、序列化对象或强兼容约束时才使用 “契约”;只读状态视图表示从权威状态派生出的查询结果。 -Phase 5 将为 Embedded/Shared TUI 冻结窄的 `TuiRuntimePort`,其范围按 Shared IPC v17 +Phase 5 已在 `CliAgentRuntimeClient` 内部交付窄的 `TuiRuntimePort`,其范围按 Shared IPC v18 实际承载的 Session、Turn、Permission/UserInput、Workspace、lineage、usage/settlement、 model/mode 更新、agent mode catalog 和事件订阅确定。Model/Skill/Subagent/MCP、Account、 Settings Sync、Worktree、External Source 和 Hook 等管理面不组成一个 `TuiManagementPort`, -也不因为存在 TUI 用例就进入 Shared Runtime wire;它们由 TUI backend composition 按 domain -直接依赖 owner-owned 的稳定 service/provider trait。只有需要 TUI DTO、权限/上下文适配、 -内部类型隔离或 capability 裁剪时,才增加薄 facade。 +也不因为存在 TUI 用例就进入 Shared Runtime wire;它们由 `TuiManagementOwners` 按 domain +组合具体 Model、Registry、MCP、Account、Settings Sync、Worktree、Hook、External Source/Command +provider。只有需要 TUI DTO、权限/上下文适配、内部类型隔离或 capability 裁剪时,才增加薄 facade。 | 接口边界 | 谁使用 | 提供 | 不包含 | |---|---|---|---| @@ -488,8 +488,9 @@ client 或未来 CLI/HarmonyOS 计划,不能证明同名 Rust transport adapte 前后端契约按能力语义归属,不按 Tauri command 名称归属。稳定的请求、响应、状态事实和类型化错误放在对应 `contracts/*`、Agent Runtime API 或能力归属模块。当前 Desktop GUI 仍使用 Tauri adapter,Web UI 使用 loopback WebSocket -App Server,Embedded TUI 当前使用 in-process App Server,Shared TUI 通过 `TuiBackend` 映射 private Runtime IPC v17。目标是让 -Embedded GUI/TUI 通过 Host-owned direct adapter 调用 Runtime typed API,需要连接边界的 Web/Shared Rich Client 使用 App Server; +App Server,Embedded TUI 通过 `CliAgentRuntimeClient -> TuiRuntimePort::Embedded(AgentRuntime)` 直接调用 Runtime,Shared TUI 通过 +`TuiRuntimePort::Shared(RuntimeIpcClient)` 映射 private Runtime IPC v18。Desktop GUI 的后续目标是通过 Host-owned direct adapter 调用 +Runtime typed API;需要连接边界的 Web/Shared Rich Client 才使用 App Server; Tauri 和各 Rich Client Host 负责 adapter、transport、平台能力及生命周期。ACP、Headless CLI、Peer Host 与公开 SDK 继续由各自 adapter 映射到稳定 owner 接口,不因该目标复用 App Server wire。该规则降低框架耦合,但不要求把 controller-local Desktop DTO 搬进共享 crate。 @@ -499,7 +500,7 @@ adapter 映射到稳定 owner 接口,不因该目标复用 App Server wire。 | 能力归属模块 / Agent Runtime API | 字段明确的请求和响应、状态事实、权限/取消规则、与框架无关的用例方法 | `tauri::State`、`AppHandle`、窗口/菜单对象、command 宏、HTTP/WebSocket/ACP/SDK Host 消息结构 | | Desktop Tauri / product Host adapter | 当前组装 Tauri adapter;目标按部署组装 direct Runtime adapter 或 App Server transport、注入真实 capability 与平台 provider、管理窗口和桌面生命周期、投递 typed Runtime/App Server notification 或桌面专属事件 | 复制业务校验、持有第二份权威状态、在目标迁移完成后为同一能力保留第二条 Runtime 旁路、把 Tauri 类型传入下层 | | Server / Remote adapter | 路由鉴权、协议消息、连接生命周期、流量控制与取消转换 | 为同一能力另建业务含义不同的 DTO 或 handler | -| GUI / Web / TUI frontend | 当前依赖各自 infrastructure 或 `TuiBackend`;目标依赖 frontend/app infrastructure,由其组合 `TuiRuntimePort`、owner service/provider adapter 和需要时的 App Server client;各自保留渲染状态 | 在 UI component/view 中直接依赖 Runtime/Core/Service、公开 Python/TypeScript SDK、Tauri 业务 command 或私有 Shared IPC | +| GUI / Web / TUI frontend | 依赖各自 frontend/app infrastructure;CLI 由 `CliAgentRuntimeClient` 组合 Direct/Shared Runtime,并由 `TuiManagementOwners` 组合具体 owner provider;Web 在需要连接边界时使用 App Server client;各自保留渲染状态 | 在 UI component/view 中直接依赖 Runtime/Core/Service、公开 Python/TypeScript SDK、Tauri 业务 command 或私有 Shared IPC | 本文其他章节和历史设计中出现的“Runtime SDK”,如果指 `agent-runtime::sdk`,统一称为 **Rust Runtime SDK(当前 preview)**;它是共享 **Agent Runtime API** 的当前 Rust 入口。只有 @@ -547,8 +548,8 @@ Desktop command 使用的序列化对象继续留在 `src/apps/desktop`;即使 ## 4. 运行协作细节 -本节在 Process View Level 0 之下展开产品入口、插件调用和平台能力。Current 图只描述当前已接线请求路径;Approved Embedded target -和 Optional Shared proposal 分别描述已批准但未交付的 Embedded direct-runtime,以及仍待评审的 Shared App Server。三者都只描述组件协作, +本节在 Process View Level 0 之下展开产品入口、插件调用和平台能力。Current 图只描述当前已接线请求路径;Delivered Embedded path +和 Optional Shared proposal 分别描述已交付的 Embedded direct-runtime,以及仍待评审的 Shared App Server。三者都只描述组件协作, 不构成新的 4+1 视图。 ### 4.1 Current product entry paths @@ -557,23 +558,25 @@ Desktop command 使用的序列化对象继续留在 `src/apps/desktop`;即使 flowchart LR Desktop["Desktop GUI"] --> Tauri["Desktop / Tauri adapter"] Web["Web UI"] --> WebHost["loopback WebSocket App Server"] - TUI["Interactive TUI"] --> Backend["TuiBackend"] - Backend -->|"Embedded current"| EmbeddedAS["in-process App Server"] - Backend -->|"--shared"| SharedIPC["private Runtime IPC v17"] + TUI["Interactive TUI"] --> Client["CliAgentRuntimeClient"] + Client -->|"Embedded default"| Embedded["TuiRuntimePort::Embedded(AgentRuntime)"] + Client -->|"--shared"| SharedIPC["TuiRuntimePort::Shared(RuntimeIpcClient)
private Runtime IPC v18"] + TUI --> Management["TuiManagementOwners
concrete owner providers"] Other["Headless CLI · ACP · Server · Remote"] --> Adapter["独立入口适配器"] SDK["Rust Runtime SDK / SDK Host preview"] --> SDKAdapter["独立 SDK adapter"] Tauri --> API["Runtime API / owner ports"] WebHost --> API - EmbeddedAS --> API + Embedded --> API SharedIPC --> API + Management --> API Adapter --> API SDKAdapter --> API API --> Runtime["共享 Runtime"] ``` -当前 Embedded TUI 核心路径经过 in-process App Server,Shared TUI 通过 private Runtime IPC v17;Web UI 通过 loopback WebSocket App Server, +当前 Embedded TUI 核心路径直接调用同进程 Runtime,Shared TUI 通过 private Runtime IPC v18;Web UI 通过 loopback WebSocket App Server, Desktop GUI 通过 Tauri adapter。Headless CLI/CI、ACP、Peer Host 和 SDK Host 保留独立 adapter。所有路径最终消费同一 Runtime API 或 owner -port,部署选择不能进入业务 owner。目标路径不在本图中展开。 +port,部署选择不能进入业务 owner。 Server bootstrap 和产品组装只创建对象并注入依赖,不是客户端请求的第二条旁路: @@ -586,20 +589,20 @@ flowchart LR 图中的虚线全部表示启动期 composition;业务请求仍只沿前一张 Current 图中的实线进入 Runtime API 或 owner port。 -### 4.2 Approved Embedded target +### 4.2 Delivered Embedded path ```mermaid flowchart LR - TUI["Embedded interactive TUI"] --> Composition["TuiBackend composition"] - Composition --> RuntimePort["TuiRuntimePort"] - RuntimePort --> DirectRuntime["Direct Runtime adapter"] - DirectRuntime --> API["Runtime API / owner ports"] - Composition --> Management["owner service/provider adapters"] + TUI["Embedded interactive TUI"] --> Client["CliAgentRuntimeClient"] + Client --> RuntimePort["TuiRuntimePort::Embedded(AgentRuntime)"] + RuntimePort --> API["Runtime API / owner ports"] + TUI --> Management["TuiManagementOwners"] + Management --> Providers["concrete owner providers"] API --> Runtime["共享 Runtime"] ``` -这是已批准但尚未交付的 Embedded direct-runtime 目标,属于 Phase 5。迁移完成前,Embedded TUI 继续使用 Current 图中的 in-process -App Server;管理能力按 domain 由 composition 注入 owner service/provider,不组成 `TuiManagementPort`。 +这是 Phase 5 已交付的 Embedded direct-runtime 路径。CLI 不再组装 in-process App Server; +管理能力按 domain 由 `TuiManagementOwners` 组合具体 owner provider,不组成 `TuiManagementPort`。 ### 4.3 Optional Shared App Server proposal @@ -612,8 +615,8 @@ flowchart LR Runtime --> Storage["Workspace / Session storage"] ``` -这是 Phase 6 的待评审提案,不是当前 Shared TUI 的必经链路,也不改变 Current 图中的 private Runtime IPC v17。只有完成鉴权、实例身份、 -controller/lease、事件恢复、取消、限制、性能和回滚门槛后,才可评审是否替换 v17;评审也可以决定长期保留 v17。Web UI 不经过 TUI +这是 Phase 6 的待评审提案,不是当前 Shared TUI 的必经链路,也不改变 Current 图中的 private Runtime IPC v18。只有完成鉴权、实例身份、 +controller/lease、事件恢复、取消、限制、性能和回滚门槛后,才可评审是否替换 v18;评审也可以决定长期保留 v18。Web UI 不经过 TUI composition,而是继续通过自己的 loopback WebSocket App Server 入口。 ### 4.4 插件调用 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 69faf6c53c..de138fbb1c 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -25,7 +25,7 @@ | CLI / Desktop / ACP | CLI 与 ACP 已分别提交对应 `DeliveryProfile`、消费 Runtime Parts/SDK,并选择经过评审的 Core owner feature closure;Desktop 仍启用 `bitfun-core/product-full`,主交互已消费由现有 owner 构造的窄口径 SDK 接口 | 三个入口仍复用单一 Core runtime owner;完整 Desktop profile 和剩余兼容操作仍需逐项迁移 | | Server | 当前生产路由只形成 health/info/ping 基线 | 没有插件状态或独立产品组装完整流程 | | Server / Remote / Web / Mobile Web / SDK profile | 当前为空计划、未接入入口或仅有 preview 测试 | 不得据枚举值宣称产品能力已交付 | -| Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v6 preview 接口和 smoke test | 发布边界仍需真实嵌入方证明 | +| Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v7 preview 接口和 smoke test | 发布边界仍需真实嵌入方证明 | | 插件运行时 | 现有路径只覆盖 BitFun 原生包和 OpenCode custom tool 静态名称预览 | 不能据通用消息结构或静态候选扩张稳定 ABI | | Relay | room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,standalone 与 embedded 入口同向消费;embedded bind、静态 fallback 和任务生命周期由 Desktop 窄宿主端口持有 | Cargo metadata 门禁覆盖 workspace、独立 manifest、normal/build/dev 依赖及 optional/target 变体;宿主归位已完成并由生命周期与边界测试保护 | | CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;Linux PTY 与 Windows ConPTY 有启动页生命周期及本地确定性流式模型夹具驱动的活动 turn 进程测试,发布归档上传前校验 SHA-256 并解压执行 | 参数/序列化/前置失败和组装已有 focused contract;本地模型 HTTP 403 授权拒绝、流中断后的重试失败、Linux PTY/Windows ConPTY Chat resize/取消、`exec` Ctrl+C 及 Patch I/O 失败已有分层回归,真实供应商审批交互、macOS 活动 PTY 与 OS 级终端故障注入仍需补齐 | diff --git a/docs/plans/tui-app-server-decoupling-refactor-plan.md b/docs/plans/tui-app-server-decoupling-refactor-plan.md index 3f0b96f22b..c95f80a07b 100644 --- a/docs/plans/tui-app-server-decoupling-refactor-plan.md +++ b/docs/plans/tui-app-server-decoupling-refactor-plan.md @@ -1,8 +1,8 @@ # TUI 与 App Server 解耦重构计划 -> 状态:Phase 0-4 已完成当前定义的边界、协议基础、核心聊天、配置管理和外部集成接口迁移;Phase 5 Embedded direct-runtime 迁移待实现,Shared App Server 目标待评审。 +> 状态:Phase 5 实现切换已完成;统一跨部署 fixture、性能与升级兼容证据仍是后续门槛,Shared App Server 目标待评审。 > -> 当前状态基线:2026-08-13。一次性的运行证据保留在对应 PR/Actions 记录中;本文不绑定会因 rebase 失效的提交 SHA。 +> 当前状态基线:2026-08-14。一次性的运行证据保留在对应 PR/Actions 记录中;本文不绑定会因 rebase 失效的提交 SHA。 > > 本文只记录当前差距、阶段和完成证据。稳定架构约束见相邻架构文档;Phase 0 的历史盘点已失效,不再作为当前能力清单。 @@ -18,8 +18,8 @@ 本计划只迁移交互式 TUI 的产品后端调用: 1. TUI 保留终端输入、状态、渲染和 controller-local effect。 -2. TUI 当前通过 app-local `TuiBackend` 使用产品后端;Phase 5 再把它拆成 Runtime port 与按 domain 注入的管理接口。`TuiAgentClient` 不直接依赖 Core、Runtime 实现、具体 Service、全局 singleton 或私有 IPC operation;view/reducer 也不执行 backend I/O。 -3. Embedded TUI 当前使用 `AppServerTuiBackend`;Shared TUI 当前使用 `SharedTuiBackend` 映射 private Runtime IPC v17。Phase 5 才引入 `TuiRuntimePort`、`DirectRuntimeTuiRuntime` 和 Shared IPC Runtime adapter,并把管理用例移到 backend composition 的 owner service/provider 接口。 +2. TUI 当前通过 app-local `CliAgentRuntimeClient` 使用 Runtime;其内部私有 `TuiRuntimePort` 组合 `Embedded(AgentRuntime)` 与 `Shared(RuntimeIpcClient)`。view/reducer 不直接依赖 Core、Runtime 实现、具体 Service、全局 singleton 或私有 IPC operation,也不执行 backend I/O。 +3. 管理面由 `TuiManagementOwners` 按 domain 组合具体 Model、Registry、MCP、Account、Settings Sync、Worktree、Native/External Hook、External Source/Command provider;不建立总括性的 `TuiManagementPort`,也不把总括性的 App Server management service 搬入 CLI。 4. App Server 只适配稳定合同,不接管 Runtime、Service 或 Product Domain 的业务所有权。 5. Headless `exec`、ACP、Peer Host 和公开 SDK 保留各自经评审的 adapter。 @@ -31,75 +31,69 @@ - 为旧 Web Server 私有协议建立长期兼容层。 - 把 clipboard、editor、terminal raw mode 等 controller-local effect 下沉到工作区 Host。 -## 2. 当前路径与目标路径 +## 2. 当前路径与后续提案 ### 2.1 Current -当前 head 有两条交互式 TUI 后端路径: +当前 head 有两条交互式 TUI Runtime 部署路径: ```text Embedded TUI(当前) - -> TuiAgentClient - -> TuiBackend - -> AppServerTuiBackend - -> AppServerClient - -> private in-memory transport - -> BitfunAppServer + -> CliAgentRuntimeClient + -> TuiRuntimePort::Embedded(AgentRuntime) -> Runtime API / owners Shared TUI (--shared) - -> TuiAgentClient - -> TuiBackend - -> SharedTuiBackend compatibility adapter - -> private Runtime IPC v17 + -> CliAgentRuntimeClient + -> TuiRuntimePort::Shared(RuntimeIpcClient) + -> private Runtime IPC v18 -> Shared Runtime Host process -> Runtime API / owners ``` -当前两条路径共用的是包含 Runtime 与管理方法的单体 `TuiBackend`。`AppServerTuiBackend` -把 Embedded 请求委托给 App Server client;`SharedTuiBackend` 把 Session/chat 请求映射到 -private Runtime IPC v17,并直接持有具体 `AppManagementService` 承接 Host-local 管理能力。 -Phase 3/4 已让 controller 使用 typed backend API,不代表 Phase 5 的 port 拆分或 adapter 接线已经完成。 +两条路径共用 `CliAgentRuntimeClient` 的 typed Runtime API,但部署差异只存在于 client 私有的 +`TuiRuntimePort`。管理调用不经过该 port:controller 使用 `TuiManagementOwners` 的具体 +provider,provider 再调用真实 domain owner,并按 deployment/Remote scope 返回 capability 或 +typed unsupported。CLI 不再构造 App Server client/server、in-memory transport 或旧 backend family。 -### 2.2 Approved Embedded target +### 2.2 Delivered Phase 5 composition -Phase 5 将当前单体 backend 拆成下面的 app-local composition: +Phase 5 已交付下面的 app-local composition: ```text -TuiAgentClient - -> TuiBackend composition - -> TuiRuntimePort - -> DirectRuntimeTuiRuntime - -> SharedIpcTuiRuntime - -> owner-owned service/provider interfaces (management only) +TUI controller + -> CliAgentRuntimeClient + -> TuiRuntimePort::Embedded(AgentRuntime) + -> TuiRuntimePort::Shared(RuntimeIpcClient) -> Runtime IPC v18 + -> TuiManagementOwners + -> concrete domain owner providers (management only) ``` -这是已批准但尚未交付的 Phase 5 目标。`TuiRuntimePort` 将只覆盖 Embedded 和 Shared -都需要、且当前 private Runtime IPC v17 已经承载的 Runtime 行为:initialize/health、 -Session、Turn、Permission/UserInput、shell、compact/undo/redo/reload、usage/settlement、 +`TuiRuntimePort` 只覆盖 Embedded 和 Shared +都需要、且当前 private Runtime IPC v18 已经承载的 Runtime 行为:Session、Turn、 +Permission/UserInput、shell、compact/undo/redo/reload、usage/settlement、 workspace reference/diff、lineage、fork、当前 Session 的 model/mode 更新、agent mode -catalog 和事件订阅。Phase 5 完成后,`DirectRuntimeTuiRuntime` 将 direct Runtime 映射到 -该 port,Shared IPC adapter 将 v17 的结果和事件映射到同一组 TUI semantic types;后者 -不运行 `BitfunAppServer`,也不是 Shared App Server transport。 +catalog 和事件订阅。Embedded 分支将 direct Runtime 映射到 +该 port,Shared 分支将 v18 的结果和事件映射到同一组 TUI semantic types;后者 +不运行 `BitfunAppServer`,也不是 Shared App Server transport。initialize/health 属于 +transport lifecycle:Embedded 不再伪造该握手;Shared v18 的协议与实例校验只发生在 +`RuntimeIpcClient` 建连过程中,不进入 TUI Runtime port。 Model catalog/CRUD、Skill、Subagent、MCP、Account、Settings Sync、Worktree、External Source 和 Hook 不因为被 TUI 使用就进入 `TuiRuntimePort`,也不需要一个总括性的 -`TuiManagementPort`。backend composition 按 domain 注入各 owner 已有的稳定 service/provider -trait;Embedded 直接使用同进程 owner service,Shared 使用 Host-local service/provider -adapter。只有原始 service 接口暴露内部类型、无法表达 TUI 所需的权限/上下文/unsupported, -或需要跨部署稳定 DTO 时,才在 owning crate 或 adapter 内增加最薄的 facade。不得把 -`AppManagementService` 整体搬入 direct adapter,也不得让 controller/view 直接依赖具体 -service 实现。缺少 provider 的 Shared/Remote 场景返回 typed unsupported,禁止静默回落控制端本机。 - -Phase 3 已将 Mode/Model、Skill、Subagent 和 MCP 管理面迁移到 TUI-facing typed API。Phase 4 进一步迁移了 External Source、native/external Hook、Account、Settings Sync 和 Worktree 管理面。当前这些方法仍位于单体 `TuiBackend`;Embedded 经 App Server 的 `AppManagementService` wiring 调用 owner,Shared 则由 `SharedTuiBackend` 委托其持有的具体 `AppManagementService`。Phase 5 将按 owner 归属把管理用例拆到 service/provider 接口,不建立管理总接口;TUI controller 仍不直接访问 compatibility owner。 - -Phase 5 中,`DirectRuntimeTuiRuntime` 将实现 `TuiRuntimePort`,可以依赖 Rust Runtime SDK 暴露的稳定 typed facade,但不得把 Runtime 内部类型、Core singleton 或事件队列 owner 暴露给 TUI。管理面 backend 可以直接调用 owner-owned 的稳定 service/provider trait;若该接口暴露内部类型,或需要 TUI-specific DTO、权限、上下文和 capability 裁剪,才在 owning crate 抽取薄 facade。不得把 `AppManagementService` 原样搬到 CLI,也不得由 direct adapter 复制其业务状态或策略。 +`TuiManagementPort`。`TuiManagementOwners` 组合各 domain 的稳定 provider;只有原始 owner +接口暴露内部类型、无法表达 TUI 所需的权限/上下文/unsupported,或需要稳定 projection 时, +才在 owning crate 或 CLI adapter 内增加最薄 facade。不得把总括性的 App Server management service 搬入 CLI, +也不得让 controller/view 直接依赖具体 service 实现。缺少 provider 的 Shared/Remote 场景返回 +typed unsupported,禁止静默回落控制端本机。 -当前 Shared 的 Session/chat/mode authority 由 `SharedTuiBackend` 映射 v17;Phase 5 完成后才由 Shared IPC Runtime adapter 实现 `TuiRuntimePort`。Host 实际提供的本机管理 capability 当前由 `SharedTuiBackend` 委托具体 `AppManagementService`,Phase 5 再改为 backend composition 按 domain 调用 Host-local service/provider adapter。当前 Shared Host 提供 Phase 4 的 External Source V1 和 Hook 管理,但不注入 Account/Settings Sync 或 Worktree owner;这些能力返回 typed unsupported。Remote workspace 对所有 controller-local management capability fail closed,不回落到控制端本机。Phase 4 完成表示 typed API 和 wiring 已迁移,不表示所有 deployment 的 capability 完全相同,也不表示 Phase 5 已完成。Phase 4 之后新增的 External Application V2 控制面目前只在 Embedded App Server 接线,Shared Runtime 明确 unsupported,不重新打开 Phase 4 的旧 owner 直连预算。 +Shared 的 Session/chat/mode authority 由 `CliAgentRuntimeClient` 的 Shared 分支映射 v18; +Host-local 管理 capability 由 `TuiManagementOwners` 的具体 provider 提供。Phase 4 之后新增的 +External Application V2 控制面在 Shared Runtime 明确 unsupported,不重新打开旧 owner 直连预算。 ### 2.3 Optional Shared App Server proposal -Web 当前继续通过自己的 loopback WebSocket App Server 入口,不经过 TUI composition: +Web 当前继续通过自己的 loopback WebSocket App Server 入口,不经过 CLI Runtime/management composition: ```text Web UI -> Web Host -> loopback WebSocket App Server @@ -113,62 +107,62 @@ Shared Rich Client(Phase 6 candidate) ``` Shared App Server 仍是 Phase 6 待评审提案,不是 Phase 5 的既定结果。Private Runtime IPC -v17 在候选 transport 的鉴权、实例身份、controller/lease、事件恢复、断连取消、 +v18 在候选 transport 的鉴权、实例身份、controller/lease、事件恢复、断连取消、 `outcome_unknown`、frame 限制和空闲退出达到行为等价前继续保留;评审也可以决定长期保留 -v17。Embedded direct-runtime 不替换 v17,也不要求 direct facade 与 wire DTO 相同;两者只需 +v18。Embedded direct-runtime 不替换 v18,也不要求 direct facade 与 wire DTO 相同;两者只需 满足同一行为合同。 ## 3. 当前能力矩阵 状态定义: -- **已交付**:生产 handler/client 已接线,并被当前 Embedded TUI 路径使用。 -- **兼容映射**:Shared TUI 通过 Runtime IPC v17 和 `SharedTuiBackend` 提供与 Embedded 对照的 TUI Runtime 用例,但没有经过 App Server wire。 +- **已交付**:生产 Runtime client/provider 已接线,并被当前 Embedded TUI 路径使用。 +- **兼容映射**:Shared TUI 通过 `CliAgentRuntimeClient` 和 Runtime IPC v18 提供与 Embedded 对照的 TUI Runtime 用例,但没有经过 App Server wire。 - **部分交付**:已有合同或 handler,但 Host 能力、恢复、安全或 TUI 调用路径仍不完整。 - **未迁移**:当前 TUI 仍使用既有 compatibility owner 路径,或尚无生产接口。 - **本地保留**:属于 TUI 或 controller-local effect,不迁移。 -本矩阵的 Embedded 列记录 Phase 5 之前的 App Server 基线。Phase 5 完成后,Runtime 用例由 -`DirectRuntimeTuiRuntime` 调用 Runtime typed facade;管理用例由 backend composition 调用 -对应 owner service/provider。表中的行为合同和 Shared 对照场景保持不变。 +本矩阵的 Embedded 列记录 Phase 5 当前 direct-runtime。Runtime 用例由 +`CliAgentRuntimeClient -> Embedded(AgentRuntime)` 调用 Runtime typed facade;管理用例由 +`TuiManagementOwners` 调用对应 owner provider。表中的 Shared 对照场景继续使用 v18。 ### 3.1 核心聊天与 Session -| TUI 用例 | Embedded App Server | Shared v17 compatibility | 当前结论 | +| TUI 用例 | Embedded direct Runtime | Shared v18 compatibility | 当前结论 | | --- | --- | --- | --- | -| 初始化、版本、健康 | `app/initialize`、`app/health` | adapter 根据 v17 握手结果合成 TUI-facing initialize/health | Embedded 已交付;Shared 尚不是 App Server connection | -| Agent、Permission 事件 | `agent/event`、`agent/permissionEvent` | IPC 事件桥映射为 `AppServerEvent` | 两边均可驱动当前核心 TUI;底层恢复合同不同 | -| Config 事件 | `config/event` | 当前 Shared bridge 不投影 Config 事件 | Embedded 已接线;Shared 的 TUI-facing 管理 capability 当前由 `SharedTuiBackend` 委托其具体 `AppManagementService`,不代表 v17 已有 Config 事件;Phase 5 再拆成 owner service/provider adapter | -| 流失效与重同步 | `app/eventStreamState`、`app/syncEvents`、`session/sync` | adapter 投影 connection-local cursor、invalidation/resync 和 closed | 已有连接内 cursor/sync;没有跨连接持久 replay/resume | -| Session list/create/sync | `agent/listSessions`、`agent/createSession`、`session/sync` | list/create/atomic restore operation | 已交付;sync 包含 Runtime 状态、transcript、workspace binding 和 pending Permission | -| Session delete/rename/fork | typed App Server methods | v17 controller-scoped operations | 已交付或兼容映射;Shared 继续执行 controller/idle 规则 | -| Model/mode update | `session/updateModel`、`session/updateMode` | v17 current-controller operations | Session update wire 已覆盖;当前 Embedded 经 `AppServerTuiBackend` 提交,Shared 经 `SharedTuiBackend -> Runtime IPC v17` 提交;Phase 5 完成后再分别由 Direct/Shared Runtime adapter 实现 `TuiRuntimePort` | -| Submit/cancel/steer | typed Agent methods | v17 Turn operations | 已交付或兼容映射 | -| User Shell/UserInput | `agent/runUserShellCommand`、`agent/submitUserAnswers` | v17 typed operations | 已交付或兼容映射;执行和权限仍由 Runtime owner 持有 | -| Permission pending/respond | typed Permission methods/events | v17 pending/respond and event stream | 已交付或兼容映射 | -| Transcript/local command record | `session/readTranscript`、`session/recordLocalCommandTurn` | v17 transcript/record operation | 已交付或兼容映射 | -| Compact/undo/redo/reload | typed Session methods | v17 current-controller operations | 已交付或兼容映射 | -| Usage/settlement | `session/usage`、`session/waitForSettlement` | v17 usage/settlement operations | 已交付或兼容映射 | -| Workspace references/diff | typed Workspace methods | v17 reference/diff operations | 已交付或兼容映射 | -| Lineage query/inspect/cancel | typed Session methods | v17 root-controller operations | 已交付或兼容映射 | +| 初始化、版本、健康 | 不建立 TUI-facing transport 握手;从 direct Runtime context 构造 client | `RuntimeIpcClient` 建连时完成 v18 协议/实例校验,不向 TUI 暴露 initialize/health | transport lifecycle 不进入 `TuiRuntimePort`;Shared 尚不是 App Server connection | +| Agent、Permission 事件 | `AgentRuntime` typed subscriptions | IPC 事件桥映射为同一 TUI semantic event | 两边均可驱动当前核心 TUI;底层恢复合同不同 | +| Config / management 状态 | `TuiManagementOwners` 具体 provider | 同一 Host-local provider 组合,按 capability/scope fail closed | 不进入 v18 event wire;provider 状态由真实 owner 投影 | +| 流失效与重同步 | direct event/Permission subscriptions 与 typed session sync | adapter 投影 connection-local cursor、invalidation/resync 和 closed | 已有连接内 cursor/sync;没有跨连接持久 replay/resume | +| Session list/create/sync | direct Runtime list/create/restore methods | list/create/coherent restore operation | 已交付;restore 在 Session mutation 边界内返回 Runtime 状态与 transcript,并携带 workspace binding、pending Permission 和 Runtime 注册顺序的 pending UserInput | +| Session delete/rename/fork | direct Runtime methods | v18 controller-scoped operations | 已交付或兼容映射;Shared 继续执行 controller/idle 规则 | +| Model/mode update | `CliAgentRuntimeClient` direct Runtime methods | v18 current-controller operations | Direct/Shared 分支实现同一 `TuiRuntimePort` 行为;model catalog/CRUD 仍属于 `TuiManagementOwners::model` | +| Submit/cancel/steer | typed Agent methods | v18 Turn operations | 已交付或兼容映射 | +| User Shell/UserInput | direct Runtime shell/answers methods | v18 typed operations | 已交付或兼容映射;执行和权限仍由 Runtime owner 持有 | +| Permission pending/respond | typed Permission methods/events | v18 pending/respond and event stream | 已交付或兼容映射 | +| Transcript/local command record | direct Runtime transcript/record methods | v18 transcript/record operation | 已交付或兼容映射 | +| Compact/undo/redo/reload | typed Session methods | v18 current-controller operations | 已交付或兼容映射 | +| Usage/settlement | direct Runtime usage/settlement methods | v18 usage/settlement operations | 已交付或兼容映射 | +| Workspace references/diff | typed Workspace methods | v18 reference/diff operations | 已交付或兼容映射 | +| Lineage query/inspect/cancel | typed Session methods | v18 root-controller operations | 已交付或兼容映射 | ### 3.2 事件恢复的准确边界 -当前 App Server 已发送带 `connection_id + stream + sequence` 的 cursor,并在 server receiver lag/closed 时提供明确 resync directive。`session/sync` 可恢复 Session、Runtime 状态、transcript、workspace binding 和 pending Permission;`app/syncEvents` 返回所请求 stream 的当前 connection-local cursor 与 pending Permission snapshot,但当前不提供 Agent 或 Config snapshot。 +当前 Embedded direct path 在 restore 前先挂载 Runtime typed subscriptions,再从同一个 owner restore snapshot 恢复 Session、Runtime 状态、transcript、workspace binding、pending Permission 和 pending UserInput,不使用 App Server cursor。Shared IPC Server 同样在执行 restore 前预订阅对应 Session,执行期间事件先进入连接 buffer;restore snapshot 在 Session mutation 边界内一次捕获 state 与 transcript,避免 response/subscription 窗口丢事件。Web App Server 仍使用自己的 `connection_id + stream + sequence` 与 connection-local resync 合同;它不是 CLI 的恢复路径。 -当前未交付的是跨连接持久化 cursor、历史事件 replay 和断线后的透明 resume。Shared Runtime IPC v17 仍按自己的 lag/closed、断连取消和 controller 隔离规则工作;`SharedTuiBackend` 只为当前 TUI connection 投影单调 cursor,不能把该投影描述为底层 IPC 已有 replay。 +当前未交付的是跨连接持久化 cursor、历史事件 replay 和断线后的透明 resume。Shared Runtime IPC v18 仍按自己的 lag/closed、断连取消和 controller 隔离规则工作;`CliAgentRuntimeClient` 的 Shared 分支只为当前 TUI connection 投影单调 cursor,不能把该投影描述为底层 IPC 已有 replay。 ### 3.3 管理面状态 | Domain | 当前状态 | 当前结论 / 后续 | | --- | --- | --- | -| Mode/Model 管理 | Embedded App Server 提供 typed mode catalog 和 model list/get/add/update/delete/default API;read DTO 只含 secret configured metadata,mutation 使用 preserve/replace/clear | Phase 3 已完成 typed API 与 wiring;Shared mode catalog 来自 Runtime Host,model 目录/管理由 `SharedTuiBackend` 持有的具体 `AppManagementService` 提供,Session model mutation 由 `SharedTuiBackend` 提交给 v17 owner;Phase 5 再拆分 owner service/provider 与 Runtime port | -| Skill/Subagent | 当前 `TuiBackend` 提供 typed list/toggle API 和 visible/manageable read model;Embedded 经 App Server wiring,Shared 由 `SharedTuiBackend` 委托其具体 `AppManagementService` | Phase 3 已完成 typed API 与 wiring;Shared capability 明确属于本机 CLI compatibility scope,Phase 5 再拆成 owner service/provider adapter | -| MCP | 当前 `TuiBackend` 提供 typed catalog/status/toggle/add/delete/external decision/conflict API;read projection 与 Debug 输出不暴露凭据 | Phase 3 已完成当前定义;Shared 由 `SharedTuiBackend` 委托当前 CLI 进程的具体 `AppManagementService`,以本地 MCP compatibility service 保留迁移前管理行为。该 service 的 MCP 进程状态和 tool registry 不会即时重配已经运行的 Shared Runtime Host;要取得 Host 侧新状态仍需显式的同步/restart contract,不能把本地 toggle 描述成 v17 远端控制 | -| External Source/Tool/Command/Agent | 当前 `TuiBackend` 提供 typed snapshot/control/review、conflict choice、command expansion 和事件接口;Embedded 经 App Server wiring,Shared V1 由 `SharedTuiBackend` 委托其具体 `AppManagementService` | Phase 4 当前定义已完成;Shared 保留 V1 本机 compatibility,V2 明确 unsupported,Remote 不回落本机;Phase 5 再拆成 owner service/provider adapter | -| Hooks | 当前 `TuiBackend` 提供 typed native overview 与 external snapshot/plan/apply/mutate API;Embedded 经 App Server wiring,Shared 由 `SharedTuiBackend` 委托其具体 `AppManagementService` | Phase 4 已完成 typed API 与 wiring;native user hooks、compiled-in `post_call_hooks` 和 external hook catalog 继续分离,Remote 明确 unsupported;Phase 5 再拆成 owner service/provider adapter | -| Account/Settings Sync | typed snapshot/login/finalize/logout 与 sync start/snapshot/cancel/local-changed 已接线;凭据不进入 read model 或 Debug 输出 | Phase 4 接口迁移已完成;Embedded Host 注入共享 `AccountRuntime`,App Server 直接做 domain-to-wire 适配;当前 Shared Host 未注入并返回 typed unsupported | -| Worktree | typed repository status、bind/release 和 operation identity 已接线 | Phase 4 接口迁移已完成;Embedded Host 注入 Worktree owner,当前 Shared Host 与 Remote workspace 明确 unsupported | +| Mode/Model 管理 | `TuiManagementOwners::model` 提供 secret-safe model catalog/CRUD/default projection;mode catalog 和 current-Session mutation 属于 Runtime client | Phase 5 已拆开管理 provider 与 Runtime port;Shared Session mutation仍由 v18 owner提交 | +| Skill/Subagent | `TuiManagementOwners::registry` 提供 typed list/toggle 与 visible/manageable projection | capability 属于当前 CLI Host scope,不进入 v18 wire | +| MCP | `TuiManagementOwners::mcp` 提供 typed catalog/status/toggle/add/delete/external decision/conflict projection | provider 的 MCP 进程状态和 tool registry 属于当前 CLI 进程;不能把本地 toggle 描述成 v18 远端控制 | +| External Source/Tool/Command/Agent | `external_source` 与 `external_command` provider 提供 typed snapshot/control/review、conflict choice、command expansion 和事件接口 | Shared V1 只发布实际可用 capability;V2 与 Remote unsupported 不回落本机 | +| Hooks | `native_hook` 与 `external_hook` provider 提供 typed overview/snapshot/plan/apply/mutate API | native user hooks、compiled-in `post_call_hooks` 和 external hook catalog 继续分离,Remote 明确 unsupported | +| Account/Settings Sync | `account` / `settings_sync` provider 提供 typed snapshot/login/finalize/logout 与 sync start/snapshot/cancel/local-changed;凭据不进入 read model 或 Debug 输出 | provider 按当前 deployment/scope 调用真实 owner或返回 typed unsupported | +| Worktree | `worktree` provider 提供 typed repository status、bind/release 和 operation identity | Embedded 调用 Worktree owner;Shared/Remote 不可用时明确 unsupported | | Desktop/Web Host 安全 | WebSocket Host 仅为 loopback 单用户;Desktop 当前仍使用 Tauri adapter,独立 direct Runtime 迁移尚未实施 | Host allowlist、身份/作用域、真实 limits 与平台 capability provider | ### 3.4 本地保留 @@ -195,7 +189,7 @@ v17。Embedded direct-runtime 不替换 v17,也不要求 direct facade 与 wir | `src/crates/interfaces/app-server-protocol` | behavior-light method、DTO、wire error、event envelope 和角色定义 | | `src/crates/interfaces/app-server-client` | 类型化请求、事件分发和 host-supplied transport 抽象 | | `src/crates/interfaces/app-server` | server 生命周期、生产 handler 注册、Runtime/domain 与 wire 转换、错误映射 | -| `src/apps/cli` | 当前拥有 `TuiAgentClient`、单体 `TuiBackend`、`AppServerTuiBackend`、`SharedTuiBackend`、transport 和进程生命周期、TUI-local effect;Phase 5 再引入 Runtime port 与按 domain 注入的 owner service/provider composition | +| `src/apps/cli` | 拥有 `CliAgentRuntimeClient`、私有 `TuiRuntimePort`、Shared transport/进程生命周期、`TuiManagementOwners` 具体 provider 组合和 TUI-local effect;不拥有 App Server 或业务 owner | | Runtime/Service/Product Domain owners | Session、Turn、Permission、Workspace、配置和其他业务权威事实 | 边界规则: @@ -204,9 +198,9 @@ v17。Embedded direct-runtime 不替换 v17,也不要求 direct facade 与 wir - `bitfun-app-server` 可依赖生产 handler 所需的明确 owner feature,但禁止选择 `bitfun-core/product-full`。 - Host 负责 transport、认证、作用域、真实 capability/limits、平台能力和进程生命周期。 - handler 只做合同校验、DTO 转换和错误映射,不持有第二份业务权威状态。 -- Phase 5 引入的 `TuiRuntimePort` 只抽取 Embedded/Shared 共同需要的 Runtime 行为;不定义总括性的 `TuiManagementPort`。 -- Phase 5 将管理面按 domain 拆到 owner-owned 的稳定 service/provider trait;只有需要 DTO、权限/上下文 - 适配或 capability 裁剪时才增加薄 facade,不能把具体 service 实现或 `AppManagementService` +- Phase 5 已交付的 `TuiRuntimePort` 只抽取 Embedded/Shared 共同需要的 Runtime 行为;不定义总括性的 `TuiManagementPort`。 +- `TuiManagementOwners` 已将管理面按 domain 组合到具体 owner provider;只有需要 DTO、权限/上下文 + 适配或 capability 裁剪时才增加薄 facade,不能把具体 service 实现或总括性的 App Server management service 整体暴露给 TUI。 - DTO 提取不代表 Runtime owner 迁移。 @@ -218,20 +212,19 @@ v17。Embedded direct-runtime 不替换 v17,也不要求 direct facade 与 wir | --- | --- | --- | --- | --- | | Phase 0:边界 | `TuiBackend`、behavior-light protocol/client crate、source/Cargo guard 已建立 | Core boundary tests 和 dependency checks | 已完成 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | | Phase 1:协议基础 | initialize/health、typed events、connection-local cursor、resync、稳定错误和 Embedded connection 已接线 | App Server protocol/client/server focused tests | 已完成 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | -| Phase 2:核心聊天(旧路径) | Embedded 核心用例经 App Server;Shared 经同一 `TuiBackend` 映射 v17;TUI 核心不引用 Runtime SDK/IPC operation | CLI、App Server、Runtime IPC 和 boundary focused tests | 已完成当前定义,作为迁移基线 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | +| Phase 2:核心聊天(旧路径) | Embedded 核心用例经 App Server;Shared 经同一 `TuiBackend` 映射当时的 Runtime IPC;TUI 核心不引用 Runtime SDK/IPC operation | CLI、App Server、Runtime IPC 和 boundary focused tests | 已完成当前定义,作为迁移基线 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | | Phase 3:配置管理 | TUI controller 不再访问 config/registry/MCP compatibility owner;secret-safe typed APIs 完成,CLI Host adapter 可保留显式 compatibility forwarding | owner tests、App Server contract tests、CLI behavior tests | 已完成当前定义 | 本变更的 protocol/client/server/CLI focused tests 与 Core boundary checks | | Phase 4:外部集成 | External Source、Hook、Account、Settings Sync、Worktree 管理面经 typed backend;remote 不回落本机 | owner/remote/security contract tests | 已完成当前定义 | [PR #2146 checks](https://github.com/GCWing/BitFun/pull/2146/checks)、zero-budget contract 与 Core boundary checks | -| Phase 5:Embedded direct-runtime | `TuiRuntimePort` 与 owner service/provider 接入边界拆分完成;Embedded TUI 删除 App Server client/server 与 in-memory transport,改用 direct Runtime adapter;管理面不进入 Shared IPC,旧路径随切换删除 | Runtime port 对 Shared v17 operation 的覆盖测试;direct-runtime 与 Shared v17 的 Runtime 行为测试;各管理 service/provider 的 capability/unsupported 测试 | 待实现 | - | -| Phase 6:Shared App Server | Shared Host 达到 v17 治理等价,opt-in 双栈验证完成,并有回滚与删除证据 | 跨 transport parity、故障、性能和安全测试 | 未开始,目标待评审 | - | +| Phase 5:Embedded direct-runtime | `TuiRuntimePort` 与 owner provider 接入边界拆分完成;Embedded TUI 删除 App Server client/server 与 in-memory transport,改用 direct Runtime;管理面不进入 Shared IPC,旧路径随切换删除 | Runtime client/port focused tests;direct-runtime 与 Shared v18 的 deployment-specific 行为测试;各管理 provider 的 capability/unsupported 测试;Core boundary checks;统一跨部署 fixture 作为后续证据门槛 | 实现切换完成;统一 fixture 与性能/升级证据待补 | 本变更的 CLI focused tests 与 Core boundary checks;不把源码形状断言计作跨部署行为证据 | +| Phase 6:Shared App Server | Shared Host 达到 v18 治理等价,opt-in 双栈验证完成,并有回滚与删除证据 | 跨 transport parity、故障、性能和安全测试 | 未开始,目标待评审 | - | -### 5.1 Phase 0-2 已交付摘要 +### 5.1 Phase 0-2 历史迁移基线 -- `TuiAgentClient`、Startup 和 `ChatMode` 只消费 app-local `TuiBackend`;Runtime 调用和管理调用均不下沉到 view/reducer。 -- Embedded Host 当前在专用 OS 线程的 current-thread Tokio runtime + `LocalSet` 中运行 private `BitfunAppServer`,TUI 保持在原多线程 runtime;这是 direct-runtime 迁移前的基线。 -- `AppServerTuiBackend` 通过正式 `AppServerClient` 和 in-memory transport 完成核心用例,后续由 direct Runtime composition 替换。 -- `SharedTuiBackend` 将相同 Runtime 行为映射到 private Runtime IPC v17;TUI client/controller 不引用 IPC operation。将这些方法抽取为 Runtime port 属于 Phase 5。 +- Phase 0-2 曾由 app-local `TuiBackend` 隔离 controller 与后端,Runtime 调用和管理调用不下沉到 view/reducer;该接口 family 已在 Phase 5 删除。 +- Embedded Host 曾在专用 OS 线程运行 private `BitfunAppServer`;这是已退役的行为基线,不是当前路径。 +- `AppServerTuiBackend` / `SharedTuiBackend` 曾分别映射 App Server 与当时的 private Runtime IPC;Phase 5 已由 `CliAgentRuntimeClient` 的 Direct/Shared 分支替换。 - App Server 核心 handler 覆盖 sync、turn、Permission、revert、context、usage、settlement、Workspace 和 lineage;Config 事件也已在 Embedded connection 接线。 -- Runtime IPC v17 为当前 parity 增加 restore Runtime 状态、usage、settlement 和本地命令 transcript 记录;没有增加 replay、observer、通用 controller transfer 或公开 SDK 能力。 +- Runtime IPC v17 为当前 parity 增加 restore Runtime 状态、usage、settlement 和本地命令 transcript 记录;v18 再增加权威 pending UserInput restore 与 post-registration ready 事件。两者都没有增加 replay、observer、通用 controller transfer 或公开 SDK 能力。 - capability 声明列出当前注册方法,但 Host-specific availability 和方向性 limits 仍是后续收紧项。 ### 5.2 Phase 3 @@ -244,15 +237,15 @@ v17。Embedded direct-runtime 不替换 v17,也不要求 direct facade 与 wir - 模型、Mode、Skill、Subagent 和 MCP 使用 owner-specific typed APIs。 - secret 不出现在 read model、日志或 generic config payload 中。 -- capability 由 Host 注入的 management service、授权和健康状态决定。 -- management service unavailable 时返回明确 unsupported;Shared 的本机 compatibility forwarding 必须显式装配并发布真实 capability,不能在 Remote workspace 静默回落控制端本机。 +- capability 由真实 Host-bound owner provider、授权和健康状态决定;当前通用 App Server Host 没有管理 owner。 +- owner 未注入时返回明确 structured unsupported;Shared 的本机 compatibility forwarding 必须显式装配并发布真实 capability,不能在 Remote workspace 静默回落控制端本机。 交付摘要: - `app-server-protocol` 提供 Mode、Model、Skill、Subagent 和 MCP 的 owner-specific DTO 与 method;model read model 不返回 secret 值,model mutation 使用 preserve/replace/clear 语义。 -- App Server 由 Host 显式注入具体 `AppManagementService`,按 `tui.modes`、`tui.models`、`tui.skills`、`tui.subagents` 和 `tui.mcp` 发布真实 availability;service 缺失或 unavailable 时返回带 capability id 的 structured unsupported。 -- `AppManagementService` 位于 App Server server wiring,复用现有 config、registry、MCP 和 external-source owner,不成为第二个业务 owner;Startup 与 Chat controller 只调用 `TuiBackend` 的 typed 方法。Phase 5 才把这些方法按 domain 拆到 owner service/provider,且不会建立 direct TUI 的总管理接口。 -- `SharedTuiBackend` 继续映射 v17 mode catalog,并将 Model、Skill、Subagent 和 MCP 管理委托其持有的具体 `AppManagementService`。v17 不承载这些目录、CRUD 或 defaults;Shared 发布的是 adapter-scoped 本地 capability,current-Session model update 仍按 v17 的 controller/idle/outcome-unknown 合同提交给 Runtime Host。Phase 5 再以 owner service/provider adapter 和 Shared IPC Runtime adapter 替换这段单体 wiring。Shared MCP service 的运行态只属于当前 CLI 进程,不宣称可以即时控制已经运行的 Shared Runtime Host。 +- App Server 不保留无生产消费者的管理 service;当前通用 Server Host 未注入 management owner,相关方法返回带 capability id 的 structured unsupported。未来只有真实 Host 消费者具备 Host-bound scope/auth contract 后,才可增加按 owner 注入的窄 adapter,不能把 test-only seam 写成已交付 Web 管理能力。 +- CLI Phase 5 已改由 `TuiManagementOwners` 的具体 provider 调用真实 owner,不建立 direct TUI 总管理接口,也不复制一套 App Server 业务编排。 +- `CliAgentRuntimeClient` 的 Shared 分支继续映射 v18 mode catalog 和 current-Session model mutation;Model、Skill、Subagent 管理由 `TuiManagementOwners` 的具体 provider 提供。v18 不承载这些目录、CRUD 或 defaults。MCP 进程属于 Shared Runtime Host,controller 不启动重复 service;当前 Shared TUI 的 MCP 管理返回 typed unsupported,并要求退出 Shared clients、在 Embedded 模式管理后重启 Shared Runtime。 - Core boundary budgets 已移除 Phase 3 owner 直连债务,并要求 Startup 的 Subagent 管理继续使用 typed backend。 ### 5.3 Phase 4 @@ -270,32 +263,39 @@ v17。Embedded direct-runtime 不替换 v17,也不要求 direct facade 与 wir 交付摘要: -- `app-server-protocol`、client 和 production handlers 已提供 External Source、native/external Hook、Account、Settings Sync 与 Worktree 的 owner-specific typed API;side-effecting 请求使用 operation identity,External Source 与 Hook mutation 保留 owner revision/stale 合同,Settings Sync 提供显式取消与 snapshot。 -- `TuiAgentClient`、Startup 和 Chat controller 只经单体 `TuiBackend` 的 typed API 调用这些用例;Embedded 由 `AppServerTuiBackend` 委托 App Server wiring,Shared 由 `SharedTuiBackend` 委托 v17 或其具体 `AppManagementService`。Phase 4 涉及的 `bitfun_core`、account/account-sync compatibility marker 已从 controller 文件移除,对应 Core boundary budget 固定为零;Runtime port 与按 domain 的 management composition 仍留给 Phase 5。 -- Embedded Host 显式注入共享 `AccountRuntime` 并启用 App Server 内建的本机 Worktree 映射;App Server management service 直接适配 owner,不定义 `AccountManagementHost` 或持有第二份账户、同步、外部来源、Hook、Worktree 权威状态。CLI 的窄 `AccountRuntimeHost` 只实现 daemon、Relay/Peer 路由宿主效果,Session 备份通过独立端口读取 Agent Runtime compatibility owner。 +- `app-server-protocol` 与 client 保留 External Source、native/external Hook、Account、Settings Sync 和 Worktree 的 owner-specific typed method;通用 Host 注册的 management handlers 当前只返回 structured unsupported。未来 scoped Host adapter 的 side-effecting 请求仍必须使用 operation identity,并保留 owner revision/stale、显式取消与 snapshot 合同。 +- Startup 和 Chat controller 只经 `CliAgentRuntimeClient` 或 `TuiManagementOwners` 的 typed API 调用这些用例。Phase 4 涉及的 `bitfun_core`、account/account-sync compatibility marker 已从 controller 文件移除,对应 Core boundary budget 固定为零;Phase 5 已完成 Runtime port 与按 domain 的 management composition。 +- 已退役的 Embedded App Server 注入路径不再承担账户或 Worktree 管理;Direct CLI 通过具体 provider 复用共享 `AccountRuntime` 与 Worktree owner。未来真实 Host 若接线 App Server management,必须先接收 Host-bound scope/auth,并且只能直接适配这些 owner;不得定义 `AccountManagementHost` 或持有第二份账户、同步、外部来源、Hook、Worktree 权威状态。CLI 的窄 `AccountRuntimeHost` 只实现 daemon、Relay/Peer 路由宿主效果,Session 备份通过独立端口读取 Agent Runtime compatibility owner。 - Shared adapter 只发布 Host 实际可用的 capability。External Source V1 与 Hook 管理可使用当前本机 compatibility service;Account/Settings Sync、Worktree、Remote workspace 和后续未接线的 External Application V2 返回 typed unsupported,不静默回落本机。 -- Phase 4 未扩展 private Runtime IPC v17,也未改变 Phase 6 的 Shared transport 评审门槛。 +- Phase 4 未扩展当时的 private Runtime IPC v17,也未改变 Phase 6 的 Shared transport 评审门槛。 ### 5.4 Phase 5 -Embedded direct-runtime Phase 5 建议顺序: +Embedded direct-runtime Phase 5 已完成当前定义: -1. 按当前 Shared IPC v17 operation 集合冻结窄 `TuiRuntimePort`,定义统一的 TUI semantic request/result/event/error;direct facade 与 v17 wire 不要求共享 DTO。 -2. 将当前包含 Runtime 和管理面方法的单体 `TuiBackend` 拆为 backend composition:Runtime 调用进入 `TuiRuntimePort`,Model/Skill/Subagent/MCP/Account/Settings/Worktree/External Source/Hook 按 domain 进入各自 owner service/provider 接口;不创建 `TuiManagementPort` 总接口。 -3. 逐项检查管理 service 的暴露面:能直接复用稳定 owner-owned trait 的直接注入;暴露内部类型或需要 TUI DTO、权限/上下文、capability 裁剪的,才抽取最薄 facade。`AppManagementService` 仅保留为 App Server wiring,不原样迁入 CLI。 -4. 为 `DirectRuntimeTuiRuntime` 和 Shared IPC adapter 实现 Runtime port;再将 Embedded Host 从 `EmbeddedAppServerHost` 切换为 direct Runtime Host,删除 in-memory transport、App Server thread 和 initialize/health wire handshake。 -5. 按 Chat、Session、Permission/UserInput、Workspace、Config/Management 垂直切片迁移并验证事件订阅、pending Permission、取消、unknown outcome、workspace/execution binding、错误映射和 Host shutdown 回收。 -6. 完成 direct Runtime 的性能、升级兼容和跨入口行为验证后删除旧 App Server;不保留 rollback adapter,direct adapter 不支持的能力必须返回 typed unsupported。 +1. `CliAgentRuntimeClient` 内部的窄 `TuiRuntimePort` 以 `Embedded(AgentRuntime)` 与 + `Shared(RuntimeIpcClient)` 统一 TUI semantic request/result/event/error;direct facade 与 v18 + wire 不共享 DTO。 +2. Runtime 与管理边界已拆开:Runtime 调用进入 client 私有 port;Model/Skill/Subagent/MCP/ + Account/Settings/Worktree/External Source/Hook 按 domain 进入 `TuiManagementOwners` 的具体 provider, + 不创建 `TuiManagementPort` 总接口。 +3. App Server 不保留无生产消费者的 management service;相关方法保持 structured unsupported, + CLI provider 复用真实 owner use case 或最薄 projection,不复制业务状态和策略。 +4. Embedded Host 已切换为 direct Runtime,CLI 的 in-memory transport、App Server thread、 + initialize/health wire handshake 与旧 backend family 已删除。 +5. Chat、Session、Permission/UserInput、Workspace 和 Management 使用 focused tests 与 boundary rules + 锁定事件、取消、unknown outcome、workspace/execution binding、错误映射和 shutdown 行为。 +6. 旧 App Server 不作为 rollback adapter;缺少的 deployment/provider capability 返回 typed unsupported。 -Shared App Server Phase 6 不以“删除 v17”为起点。建议顺序: +Shared App Server Phase 6 不以“删除 v18”为起点。建议顺序: 1. 在 Shared Host 中增加默认关闭的 App Server local transport。 2. 两条 transport 复用同一 Host-scoped connection authority、controller registry、Session 事件过滤、operation identity/deadline/cancel 和未知结果登记。 3. 使用一个第一方 Rich Client 进行 opt-in 双栈验证,覆盖跨 transport 竞争、断连、迟到结果、Host 崩溃和回滚。 4. 记录 startup、延迟、内存、frame/queue 上限和长期维护成本。 -5. 只有行为、安全、恢复和性能达到完成门槛后,才评审是否切换 `--shared` 默认实现并删除 v17。 +5. 只有行为、安全、恢复和性能达到完成门槛后,才评审是否切换 `--shared` 默认实现并删除 v18。 -保留 private v17 作为稳定终态也是允许的:只要业务用例和 owner 仍统一,物理 wire 不必为了形式统一而提前收敛。 +保留 private v18 作为稳定终态也是允许的:只要业务用例和 owner 仍统一,物理 wire 不必为了形式统一而提前收敛。 ## 6. 验证 @@ -323,19 +323,19 @@ Phase 0-2 的具体命令结果和 CI 状态保留在 [PR #2034 checks](https:// | Workspace | binding、references、diff、remote facts | | Lineage | tree、descendant transcript、settlement、targeted cancellation | | Failure | unsupported、lag、invalidated、disconnect、deadline、`outcome_unknown` | -| Deployment | 当前覆盖 Embedded App Server 与 Shared v17 compatibility;Phase 5 切换到 direct-runtime 并删除旧 Embedded App Server | +| Deployment | 当前分别覆盖 Embedded direct Runtime 与 Shared v18 compatibility 的关键路径;旧 Embedded App Server 不再是测试或回滚路径 | -Embedded direct-runtime 实现后,同一 fixture 必须覆盖 direct Runtime 和 Shared v17;迁移前可用旧 Embedded App Server 建立基线,但不维护第三条回滚路径。Shared App Server 实现后再增加候选 transport 路径。 +当前 focused tests 分别覆盖 direct Runtime 和 Shared v18 的关键行为;统一跨部署 fixture 尚未完成,仍是后续验证门槛。旧 Embedded App Server 只保留在历史证据中,不维护第三条回滚路径;Shared App Server 实现后再增加候选 transport 路径。 ## 7. 完成定义 只有同时满足以下条件,才能宣布 TUI/App Server 解耦完成: -1. Phase 3/4 当前定义的管理面已迁移;Phase 5 direct-runtime 已完成;后续新增 capability 也不得绕过 backend composition 或恢复旧 owner 直连。 -2. TUI 产品请求和订阅经过 `TuiAgentClient` 的 backend composition;Runtime 行为经过 `TuiRuntimePort`,管理能力经过对应 owner service/provider,TUI view/reducer 不执行 backend I/O。 +1. Phase 3/4 当前定义的管理面已迁移;Phase 5 direct-runtime 已完成;后续新增 capability 也不得绕过 `CliAgentRuntimeClient` / `TuiManagementOwners` composition 或恢复旧 owner 直连。 +2. TUI 产品请求和订阅经过 `CliAgentRuntimeClient`;Runtime 行为经过其私有 `TuiRuntimePort`,管理能力经过 `TuiManagementOwners` 的具体 owner provider,TUI view/reducer 不执行 backend I/O。 3. protocol/client 和 TUI-facing 依赖闭包不包含 Core、Runtime/Service 实现、`product-full` 或 private IPC operation;只有 CLI Host/backend composition 可以按 owner 注入已审核的 service/provider。 4. capability、limits、身份和作用域来自真实 Host/transport,而不是通用 protocol 默认值;管理 service/provider 缺失时返回 typed unsupported。 -5. 事件、断线、恢复、权限、取消和 unknown outcome 有明确合同与故障测试;Runtime port 的每个 Shared v17 operation 都有对应覆盖证据。 +5. 事件、断线、恢复、权限、取消和 unknown outcome 有明确合同与故障测试;Runtime port 的每个 Shared v18 operation 都有对应覆盖证据。 6. remote workspace 不存在 controller-local fallback。 -7. 重复 DTO、无效 handler、70 方法单体管理 trait 和无生产消费方的旁路已删除或不再属于稳定边界。 -8. 旧 Embedded App Server 已删除且不再作为 rollback adapter;若采用 Shared App Server,迁移满足 Phase 6 的双栈、回滚、性能、安全和删除门槛;否则文档明确 v17 是保留的私有 compatibility transport。 +7. 重复 DTO、无效 handler、旧单体 `TuiBackend` family 和无生产消费方的旁路已删除或不再属于稳定边界。 +8. 旧 Embedded App Server 已删除且不再作为 rollback adapter;若采用 Shared App Server,迁移满足 Phase 6 的双栈、回滚、性能、安全和删除门槛;否则文档明确 v18 是保留的私有 compatibility transport。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19d9efbb99..6165e76ee6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: vite: specifier: ^7.0.4 version: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: + specifier: ^4.1.0 + version: 4.1.0(@types/node@22.19.7)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) ws: specifier: ^8.19.0 version: 8.19.0 diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 9f5b3bd30e..b704373879 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -1461,10 +1461,9 @@ const CLI_REVIEWED_CORE_FEATURES = [ ]; const APP_SERVER_REVIEWED_CORE_FEATURES = [ - 'external-sources', + 'agent-runtime', 'git', 'i18n-runtime', - 'remote-connect', ]; test('SDK Host Core capability closure keeps every reviewed owner', () => { @@ -1609,6 +1608,30 @@ test('App Server Core capability closure keeps its production Git owner', () => ]); }); +test('App Server Core capability closure keeps its Agent configuration owner', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const appServer = packageAt( + 'bitfun-app-server', + 'src/crates/interfaces/app-server/Cargo.toml', + [pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: APP_SERVER_REVIEWED_CORE_FEATURES.filter( + (feature) => feature !== 'agent-runtime', + ), + })], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [appServer, core], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.deepEqual(violations.map((violation) => violation.message), [ + 'bitfun-app-server Core capability closure must include agent-runtime', + ]); +}); + test('App Server Core capability closure keeps its backend i18n runtime', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); const appServer = packageAt( diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 523c3ab192..c6f0f96dfa 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -914,10 +914,9 @@ export function findProductEntrypointCoreFeatureViolations( ]], ['bitfun-acp', [...new Set([...acpClientCoreFeatures, ...acpServerCoreFeatures])]], ['bitfun-app-server', [ - 'external-sources', + 'agent-runtime', 'git', 'i18n-runtime', - 'remote-connect', ]], ['bitfun-sdk-host-app', coreCompatibilityReviewedFeatures], ]); diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index f298e2cb39..5817329151 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -19,19 +19,6 @@ export const forbiddenContentRules = [ }, ], }, - { - path: 'src/apps/cli/src/tui_backend.rs', - reason: - 'The CLI-local TUI backend may consume App Server client and wire contracts but must not depend on backend implementations, Runtime, services, or private IPC', - patterns: [ - { - regex: - /\b(?:bitfun_core|bitfun_agent_runtime|bitfun_agent_runtime_ipc|bitfun_services_core|bitfun_services_integrations|bitfun_runtime_services|bitfun_product_capabilities|bitfun_external_sources|bitfun_app_server)::/, - message: - 'CLI-local TuiBackend must stay on the App Server client/protocol boundary', - }, - ], - }, { path: 'src/crates/interfaces/app-server/Cargo.toml', reason: 'App Server must select explicit bitfun-core owner features', diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 387c542529..bfd0d4ac1a 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -1126,6 +1126,15 @@ export const externalSourceCorePublicApiEntries = [ 'BitFun CLI and desktop host APIs', ), ), + ...['ExternalSourceReviewAction', 'review_external_source'].map((symbol) => ({ + ...externalSourceEntry( + symbol, + 'bitfun-core external source review use-case owner', + 'BitFun CLI management provider', + ), + verification: + 'core external source mutation tests plus CLI management provider tests', + })), externalReferenceEntry( 'workspace_reference_snapshot', 'bitfun-core workspace Reference composition facade', diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 7ee0352d83..a981c5c783 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -3055,8 +3055,12 @@ export const requiredContentRules = [ message: 'missing user input manager answer regression', }, { - regex: /\buser_input_manager_cancel_closes_receiver\b/, - message: 'missing user input manager cancel regression', + regex: /\bpub fn cancel_registration\b/, + message: 'missing exact user input registration cancellation API', + }, + { + regex: /\buser_input_manager_cancel_closes_only_the_exact_registration\b/, + message: 'missing exact user input registration cancellation regression', }, ], }, @@ -8219,64 +8223,92 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/interfaces/app-server/src/management/service.rs', + path: 'src/apps/cli/src/agent/runtime_client.rs', reason: - 'App Server owns the concrete management adapter while Hosts retain explicit service injection and capability scope', + 'The CLI Agent Runtime client owns the Direct/Shared deployment choice behind one app-local Runtime seam', patterns: [ { - regex: /\bpub struct AppManagementService\b/, - message: 'missing concrete App Server management service', + regex: /\bpub\(crate\) struct CliAgentRuntimeClient\b/, + message: 'missing CLI Agent Runtime client', + }, + { + regex: /\benum TuiRuntimePort\b/, + message: 'missing private CLI TUI Runtime deployment port', + }, + { + regex: /\bEmbedded\(AgentRuntime\)/, + message: 'missing direct Embedded Runtime deployment', + }, + { + regex: /\bShared\(RuntimeIpcClient\)/, + message: 'missing Shared Runtime IPC deployment', }, { - regex: /\bimpl AppManagementService\b/, - message: 'missing concrete App Server management implementation', + regex: /\bfn new_direct\b/, + message: 'missing direct Runtime client constructor', }, { - regex: /\bAppManagementCapabilities::available\(\)/, - message: 'missing App Server management capability projection', + regex: /\bfn new_shared\b/, + message: 'missing Shared Runtime client constructor', }, ], }, { - path: 'src/apps/cli/src/shared_tui_backend.rs', + path: 'src/apps/cli/src/tui_management.rs', reason: - 'Shared TUI must retain local Model, Skill, Subagent, and MCP compatibility management without expanding Runtime IPC or leaking owners into controllers', + 'CLI management composes concrete domain owner providers instead of an App Server service or a total management port', patterns: [ { - regex: /management: Arc/, - message: 'missing injected Shared TUI App Server management service', + regex: /\bpub\(crate\) struct TuiManagementOwners\b/, + message: 'missing CLI TUI management owner composition', + }, + { + regex: /\bpub model: ModelProvider\b/, + message: 'missing CLI model owner provider', + }, + { + regex: /\bpub registry: RegistryProvider\b/, + message: 'missing CLI skill and subagent registry owner provider', + }, + { + regex: /\bpub mcp: McpProvider\b/, + message: 'missing CLI MCP owner provider', + }, + { + regex: /\bpub account: AccountProvider\b/, + message: 'missing CLI account owner provider', }, { - regex: /\bfn management_service\b/, - message: 'missing Shared TUI management capability gate', + regex: /\bpub settings_sync: SettingsSyncProvider\b/, + message: 'missing CLI settings sync owner provider', }, { - regex: /\bfn set_management_scope_from_binding\b/, - message: 'missing Shared TUI Remote workspace compatibility guard', + regex: /\bpub worktree: WorktreeProvider\b/, + message: 'missing CLI worktree owner provider', }, { - regex: /\.list_models\(ListModelsRequest \{\}\)/, - message: 'missing Shared TUI model compatibility delegation', + regex: /\bpub native_hook: NativeHookProvider\b/, + message: 'missing CLI native hook owner provider', }, { - regex: /\.list_skills\(request\)/, - message: 'missing Shared TUI skill compatibility delegation', + regex: /\bpub external_hook: ExternalHookProvider\b/, + message: 'missing CLI external hook owner provider', }, { - regex: /\.list_subagents\(request\)/, - message: 'missing Shared TUI subagent compatibility delegation', + regex: /\bpub external_source: ExternalSourceProvider\b/, + message: 'missing CLI external source owner provider', }, { - regex: /\.list_mcp_servers\(request\)/, - message: 'missing Shared TUI MCP compatibility delegation', + regex: /\bpub external_command: ExternalCommandProvider\b/, + message: 'missing CLI external command owner provider', }, { - regex: /\bshared_management_capabilities_follow_the_local_management_service\b/, - message: 'missing Shared TUI management capability regression', + regex: /\bimpl TuiManagementOwners\b/, + message: 'missing CLI TUI management owner composition implementation', }, { - regex: /\bremote_workspace_cannot_use_the_local_management_service\b/, - message: 'missing Shared TUI Remote management scope regression', + regex: /\bpub\(crate\) async fn load\b/, + message: 'missing CLI TUI management owner composition loader', }, ], }, diff --git a/scripts/core-boundaries/rules/tui-boundary-rules.mjs b/scripts/core-boundaries/rules/tui-boundary-rules.mjs index 4487bb85c0..aeeaf4c877 100644 --- a/scripts/core-boundaries/rules/tui-boundary-rules.mjs +++ b/scripts/core-boundaries/rules/tui-boundary-rules.mjs @@ -1,7 +1,8 @@ // Ratchet for legacy backend references that still live in the CLI TUI. // Counts may decrease without updating this file. Adding a marker to a new // file, moving debt between files, or increasing a count requires migration -// through the CLI-local TuiBackend/App Server boundary instead. +// through CliAgentRuntimeClient for Runtime behavior or concrete +// tui_management domain owner providers for management behavior instead. export const tuiLegacyBackendMarkers = [ 'bitfun_core::', @@ -9,7 +10,6 @@ export const tuiLegacyBackendMarkers = [ 'bitfun_services_', 'bitfun_runtime_services', 'bitfun_agent_runtime_ipc', - 'CliAgentRuntimeClient', 'CliContextReloadClient', 'CoreAgentRuntimeCompatibility', 'crate::account::', @@ -24,9 +24,9 @@ export const tuiLegacyBackendMarkers = [ export const tuiLegacyBackendBudgets = { 'src/apps/cli/src/modes/chat.rs': { - 'bitfun_core::': 12, - 'bitfun_agent_runtime::': 3, - CoreAgentRuntimeCompatibility: 3, + 'bitfun_core::': 0, + 'bitfun_agent_runtime::': 0, + CoreAgentRuntimeCompatibility: 0, }, 'src/apps/cli/src/modes/chat/commands.rs': { 'bitfun_services_': 1 }, 'src/apps/cli/src/modes/chat/external_editor.rs': { @@ -44,36 +44,36 @@ export const tuiLegacyBackendBudgets = { 'crate::account_sync::': 0, }, 'src/apps/cli/src/modes/chat/run.rs': { - 'bitfun_core::': 2, - 'bitfun_agent_runtime::': 3, + 'bitfun_core::': 0, + 'bitfun_agent_runtime::': 0, 'bitfun_services_': 1, - 'crate::account_sync::': 1, + 'crate::account_sync::': 0, }, - 'src/apps/cli/src/modes/chat/session_lineage.rs': { 'bitfun_agent_runtime::': 1 }, + 'src/apps/cli/src/modes/chat/session_lineage.rs': { 'bitfun_agent_runtime::': 0 }, 'src/apps/cli/src/modes/chat/selection.rs': { - 'bitfun_core::': 1, - 'crate::account::': 2, + 'bitfun_core::': 0, + 'crate::account::': 0, }, 'src/apps/cli/src/modes/chat/tests.rs': { - 'bitfun_core::': 8, + 'bitfun_core::': 0, 'bitfun_agent_runtime::': 3, }, 'src/apps/cli/src/modes/chat/worktree.rs': { 'bitfun_core::': 0 }, 'src/apps/cli/src/ui/chat/popups.rs': { - 'bitfun_core::': 1, + 'bitfun_core::': 0, 'bitfun_agent_runtime::': 2, - 'crate::account::': 3, - 'crate::account_sync::': 2, + 'crate::account::': 0, + 'crate::account_sync::': 0, }, 'src/apps/cli/src/ui/chat/input.rs': { 'bitfun_agent_runtime::': 4 }, 'src/apps/cli/src/ui/chat/state.rs': { 'bitfun_agent_runtime::': 1 }, 'src/apps/cli/src/ui/composer.rs': { 'bitfun_agent_runtime::': 2 }, 'src/apps/cli/src/ui/image_paste.rs': { 'std::fs': 5 }, 'src/apps/cli/src/ui/login_form.rs': { - 'crate::account::': 1, - 'crate::account_sync::': 1, + 'crate::account::': 0, + 'crate::account_sync::': 0, }, - 'src/apps/cli/src/ui/prompt_command_shell_review.rs': { 'bitfun_core::': 2 }, + 'src/apps/cli/src/ui/prompt_command_shell_review.rs': { 'bitfun_core::': 0 }, 'src/apps/cli/src/ui/permission.rs': { 'bitfun_agent_runtime::': 2 }, 'src/apps/cli/src/ui/session_lineage_selector.rs': { 'bitfun_agent_runtime::': 1 }, 'src/apps/cli/src/ui/startup.rs': { diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 87082f679c..87224b0405 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1851,6 +1851,15 @@ export function runManifestParserSelfTest({ throw new Error(`external source core public API budget is missing workspace Reference symbol: ${requiredSymbol}`); } } + for (const requiredSymbol of ['ExternalSourceReviewAction', 'review_external_source']) { + if (!externalSourceCorePublicApiRule?.allowedSymbolEntries.some( + (entry) => entry.symbol === requiredSymbol + && entry.consumer + && entry.verification, + )) { + throw new Error(`external source core public API budget is missing shared review use case: ${requiredSymbol}`); + } + } for (const requiredSymbol of [ 'ExternalMcpTimeouts', 'MAX_EXTERNAL_MCP_TIMEOUT_MS', @@ -2868,7 +2877,8 @@ export function runManifestParserSelfTest({ 'get_user_input_manager', 'validate_ask_user_question_input', 'user_input_manager_delivers_answer_and_clears_channel', - 'user_input_manager_cancel_closes_receiver', + 'cancel_registration', + 'user_input_manager_cancel_closes_only_the_exact_registration', ], }, { @@ -4268,25 +4278,32 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/interfaces/app-server/src/management/service.rs', + path: 'src/apps/cli/src/agent/runtime_client.rs', contracts: [ - 'pub struct AppManagementService', - 'impl AppManagementService', - 'AppManagementCapabilities::available\\(\\)', + 'pub\\(crate\\) struct CliAgentRuntimeClient', + 'enum TuiRuntimePort', + 'Embedded\\(AgentRuntime\\)', + 'Shared\\(RuntimeIpcClient\\)', + 'fn new_direct', + 'fn new_shared', ], }, { - path: 'src/apps/cli/src/shared_tui_backend.rs', + path: 'src/apps/cli/src/tui_management.rs', contracts: [ - 'management: Arc', - 'fn management_service', - 'fn set_management_scope_from_binding', - '\\.list_models\\(ListModelsRequest \\{\\}\\)', - '\\.list_skills\\(request\\)', - '\\.list_subagents\\(request\\)', - '\\.list_mcp_servers\\(request\\)', - 'shared_management_capabilities_follow_the_local_management_service', - 'remote_workspace_cannot_use_the_local_management_service', + 'pub\\(crate\\) struct TuiManagementOwners', + 'pub model: ModelProvider', + 'pub registry: RegistryProvider', + 'pub mcp: McpProvider', + 'pub account: AccountProvider', + 'pub settings_sync: SettingsSyncProvider', + 'pub worktree: WorktreeProvider', + 'pub native_hook: NativeHookProvider', + 'pub external_hook: ExternalHookProvider', + 'pub external_source: ExternalSourceProvider', + 'pub external_command: ExternalCommandProvider', + 'impl TuiManagementOwners', + 'pub\\(crate\\) async fn load', ], }, { diff --git a/scripts/core-boundaries/tui-boundary-ratchet.mjs b/scripts/core-boundaries/tui-boundary-ratchet.mjs index 5e5e77ae07..32094d993c 100644 --- a/scripts/core-boundaries/tui-boundary-ratchet.mjs +++ b/scripts/core-boundaries/tui-boundary-ratchet.mjs @@ -54,7 +54,7 @@ export function checkTuiLegacyBackendRatchet(root) { path: filePath, line: 1, message: - `TUI backend direct-call debt must only decrease; marker ${marker} has ${actual} occurrences, budget ${allowed}. Route new backend work through the CLI-local TuiBackend and App Server`, + `TUI backend direct-call debt must only decrease; marker ${marker} has ${actual} occurrences, budget ${allowed}. Route Runtime behavior through CliAgentRuntimeClient and management behavior through concrete tui_management owner providers`, }); } } diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index 7098a99fbc..9bd09d0763 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -19,7 +19,7 @@ CLI owns only surface concerns: - TUI state, rendering, popups, local draft history, and local effects such as clipboard or external-editor integration - projection of Runtime events into text, JSON, JSONL, and user diagnostics -- Shared Runtime client/server adaptation and Peer Device host presentation +- Direct/Shared Runtime adaptation and Peer Device host presentation Session, turn, model round, tool execution, permissions, cancellation, persistence, context, workspace binding, MCP, Subagent, and other product facts @@ -35,18 +35,37 @@ runtime owner moved. Normal interactive submissions follow: ```text -ChatView -> TuiAgentClient -> TuiBackend -> App Server client - -> App Server handler -> Agent Runtime owner -> ToolPipeline +ChatView -> CliAgentRuntimeClient -> TuiRuntimePort + -> Embedded(AgentRuntime) -> Agent Runtime owner -> ToolPipeline + -> Shared(RuntimeIpcClient) -> private Runtime IPC v18 + -> Agent Runtime owner -> ToolPipeline ``` -Embedded TUI uses an in-memory App Server connection. During the migration, -Shared TUI uses a CLI Host compatibility adapter that implements `TuiBackend` -over the private versioned Runtime IPC; the TUI client and controllers must not -reference that IPC. Embedded direct-runtime migration belongs to Phase 5; -replacing the physical Shared transport with App Server Pipe/UDS framing belongs -to the separately reviewed Phase 6. Side-effecting operations need stable -identities, controller/idle rules, bounded frames, and outcome-unknown handling -before a connection can retry. +Phase 5 is delivered: the default interactive TUI calls the in-process +`AgentRuntime` directly, while explicit `--shared` uses the private versioned +Runtime IPC. `TuiRuntimePort` is private to `CliAgentRuntimeClient`; views and +controllers must not depend on Runtime, Core, or IPC deployment details. The +retired CLI path must not reintroduce `TuiBackend`, `AppServerTuiBackend`, +`SharedTuiBackend`, an embedded App Server, or an in-memory App Server transport. + +Management operations use a separate composition path: + +```text +TUI controller -> TuiManagementOwners + -> concrete Model / Registry / MCP / Account / Settings Sync / + Worktree / Native Hook / External Hook / External Source / + External Command providers + -> owning domain service +``` + +This composition is intentionally not a total `TuiManagementPort` and does not +route management through Runtime IPC or create CLI-local business owners or +authoritative management state. +Providers retain capability and Remote-workspace guards. Web/App Server remains +a sibling product adapter. Replacing the physical Shared transport belongs to a +separately reviewed phase. Side-effecting operations need stable identities, +controller/idle rules, bounded frames, and outcome-unknown handling before a +connection can retry. Explicit Shell input follows: diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index e98d8add2d..d29fbe3e1c 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -60,9 +60,6 @@ bitfun-runtime-services = { path = "../../crates/execution/runtime-services" } bitfun-services-core = { path = "../../crates/services/services-core", features = ["dispatch-workspace", "local-storage", "process-runtime", "runtime-ownership"] } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } bitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["external-sources"] } -bitfun-app-server = { path = "../../crates/interfaces/app-server" } -bitfun-app-server-client = { path = "../../crates/interfaces/app-server-client" } -bitfun-app-server-protocol = { path = "../../crates/interfaces/app-server-protocol" } # CLI framework clap = { workspace = true } diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index c85e359182..c16f3a485d 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -143,7 +143,7 @@ pub(crate) fn shared_tui_image_attachment_error() -> String { format!("Image attachments are unavailable in Shared TUI. {SHARED_TUI_EMBEDDED_HANDOFF}.") } pub(crate) const SHARED_TUI_HELP_NOTE: &str = - "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `View subagents` in the command palette to inspect this Session's subagents; use `/timeline` to navigate user messages, `/fork` to branch the current idle Session, `/rename ` to rename it, `/compact` to compact its context, `/diff` to review workspace changes, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` and `/connect` to manage models, `/skills` to manage skills, `/mcp` to manage MCP servers, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model, Skill, Subagent, and MCP management use this CLI process's local compatibility owner; MCP process state and tool registration are local to this CLI process and do not reconfigure an already-running Shared Runtime Host. Extensions, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; + "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `View subagents` in the command palette to inspect this Session's subagents; use `/timeline` to navigate user messages, `/fork` to branch the current idle Session, `/rename ` to rename it, `/compact` to compact its context, `/diff` to review workspace changes, `/usage` to inspect authoritative Session usage, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` and `/connect` to manage models, `/skills` to manage skills, and `/reload [skills|instructions]` to refresh declarative context for the next message. MCP management is unavailable in Shared TUI because MCP processes belong to the Shared Runtime Host; exit Shared clients, manage MCP in Embedded mode, then restart Shared Runtime. Remote workspace Sessions fail closed instead of falling back to controller-local management. Extensions, account-sync, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; impl ActionHandler { pub(crate) const fn available_in_shared_tui(self, _context: ActionContext) -> bool { @@ -168,6 +168,7 @@ impl ActionHandler { | Self::Status | Self::WorkspaceDiff | Self::CompactSession + | Self::Usage | Self::Editor | Self::PromptStash | Self::PromptStashPop @@ -2136,13 +2137,13 @@ mod tests { ActionHandler::NativeHooks, ActionHandler::ExternalHooks, ActionHandler::Login, - ActionHandler::Usage, ] { assert!( !action.available_in_shared_tui(ActionContext::Chat), "{action:?}" ); } + assert!(ActionHandler::Usage.available_in_shared_tui(ActionContext::Chat)); assert!(ActionHandler::SelectModel.available_in_shared_tui(ActionContext::Startup)); assert!(ActionHandler::AddModel.available_in_shared_tui(ActionContext::Startup)); assert!(ActionHandler::Skills.available_in_shared_tui(ActionContext::Startup)); @@ -2159,10 +2160,10 @@ mod tests { assert!(SHARED_TUI_HELP_NOTE.contains("`/reload [skills|instructions]`")); assert!(SHARED_TUI_HELP_NOTE.contains("Ctrl+D")); assert!(SHARED_TUI_HELP_NOTE.contains("idle, non-current Session")); - assert!(SHARED_TUI_HELP_NOTE.contains("local compatibility owner")); - assert!(SHARED_TUI_HELP_NOTE - .contains("do not reconfigure an already-running Shared Runtime Host")); - assert!(SHARED_TUI_HELP_NOTE.contains("remain Embedded")); + assert!(SHARED_TUI_HELP_NOTE.contains("MCP management is unavailable")); + assert!(SHARED_TUI_HELP_NOTE.contains("Remote workspace Sessions fail closed")); + assert!(SHARED_TUI_HELP_NOTE.contains("restart Shared Runtime")); + assert!(SHARED_TUI_HELP_NOTE.contains("`/usage`")); } #[test] @@ -2320,7 +2321,7 @@ mod tests { } #[test] - fn shared_tui_projections_expose_compatibility_management_actions() { + fn shared_tui_projections_expose_supported_and_recoverable_management_actions() { let state = ActionState::chat(false, false).for_shared_tui(); let slash_ids = slash_actions(state) .into_iter() @@ -2331,10 +2332,12 @@ mod tests { .map(|action| action.id) .collect::>(); - for unavailable in ["tools", "extensions", "hooks", "usage"] { + for unavailable in ["tools", "extensions", "hooks"] { assert!(!slash_ids.contains(&unavailable), "{unavailable}"); assert!(!palette_ids.contains(&unavailable), "{unavailable}"); } + assert!(slash_ids.contains(&"usage")); + assert!(palette_ids.contains(&"usage")); for available in [ "new_session", "sessions", diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 906a667ffc..ced7511882 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -14,30 +14,31 @@ use tokio::sync::{broadcast, Mutex}; use bitfun_agent_runtime::sdk::{ AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, AgentEventReceiver, AgentInputAttachment, AgentMessageWorkspaceReferencesRequest, AgentRuntime, - AgentSessionCompactionRequest, - AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionForkBeforeTurnRequest, - AgentSessionForkRequest, AgentSessionForkResult, AgentSessionLineageCancellationRequest, - AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, - AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionModeUpdateRequest, - AgentSessionModelUpdateRequest, AgentSessionRenameRequest, AgentSessionRestoreRequest, - AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionUsageRequest, - AgentTurnCancellationRequest, AgentTurnSettlementRequest, AgentUserAnswersRequest, - AgentUserShellCommandRequest, AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, - AgentWorkspaceReferenceSearchResult, DialogSteerOutcome, PermissionReply, PermissionRequest, - PermissionRequestEventReceiver, PortError, PortErrorKind, RuntimeError, SessionTranscript, - SessionTranscriptRequest, SessionUsageReport, WorkspaceDiffSnapshot, + AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, + AgentSessionForkBeforeTurnRequest, AgentSessionForkRequest, AgentSessionForkResult, + AgentSessionLineageCancellationRequest, AgentSessionLineageInspection, + AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionLineageTranscriptRequest, + AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, + AgentSessionRenameRequest, AgentSessionRestoreRequest, AgentSessionRevertRequest, + AgentSessionRevertResult, AgentSessionUsageRequest, AgentTurnCancellationRequest, + AgentTurnSettlementRequest, AgentUserAnswersRequest, AgentUserShellCommandRequest, + AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, + AgentWorkspaceReferenceSearchResult, DialogSteerOutcome, DialogSubmitOutcome, PendingUserInput, + PermissionReply, PermissionRequest, PermissionRequestEventReceiver, PortError, PortErrorKind, + RuntimeError, SessionState, SessionTranscript, SessionTranscriptRequest, SessionUsageReport, + WorkspaceDiffSnapshot, }; use bitfun_agent_runtime_ipc::{ RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeIpcStreamInvalidationReason, RuntimeSessionForkRequest, RuntimeSessionRenameRequest, - RuntimeSessionRestoreRequest, RuntimeUserAnswersRequest, + RuntimeSessionRestoreRequest, RuntimeSessionState, RuntimeUserAnswersRequest, }; use bitfun_events::{AgenticEvent, AgenticEventEnvelope}; use bitfun_runtime_ports::{ - put_agent_workspace_references, AgentSessionSummary, AgentSessionWorkspaceBinding, - AgentSessionWorkspaceRequest, AgentSubmissionSource, DialogSubmissionPolicy, - SessionExecutionTarget, + put_agent_workspace_references, AgentContextReloadRequest, AgentSessionSummary, + AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionSource, + DialogSubmissionPolicy, SessionExecutionTarget, }; use crate::actions::SHARED_TUI_EMBEDDED_HANDOFF; @@ -57,6 +58,60 @@ fn shared_restore_error(error: RuntimeIpcClientError) -> anyhow::Error { with_session_conflict_help(error) } +fn shared_runtime_error(error: RuntimeIpcClientError) -> RuntimeError { + let kind = match &error { + RuntimeIpcClientError::Remote(remote) => match remote.code { + RuntimeIpcErrorCode::SessionInUse => PortErrorKind::SessionInUse, + RuntimeIpcErrorCode::InvalidRequest => PortErrorKind::InvalidRequest, + RuntimeIpcErrorCode::OutcomeUnknown => PortErrorKind::OutcomeUnknown, + RuntimeIpcErrorCode::NotFound => PortErrorKind::NotFound, + _ => PortErrorKind::NotAvailable, + }, + RuntimeIpcClientError::Timeout => PortErrorKind::Timeout, + RuntimeIpcClientError::RequestEncoding(_) | RuntimeIpcClientError::InvalidTimeout => { + PortErrorKind::InvalidRequest + } + _ => PortErrorKind::NotAvailable, + }; + RuntimeError::Port(PortError::new(kind, error.to_string())) +} + +fn merge_authoritative_remote_fact( + label: &str, + requested: &mut Option, + authoritative: Option, +) -> Result<()> { + match (requested.as_deref(), authoritative.as_deref()) { + (Some(requested), Some(authoritative)) if requested != authoritative => { + Err(anyhow::anyhow!( + "Session usage {label} conflicts with the authoritative Session workspace binding" + )) + } + (None, Some(authoritative)) => { + *requested = Some(authoritative.to_string()); + Ok(()) + } + _ => Ok(()), + } +} + +fn merge_session_usage_remote_scope( + mut request: AgentSessionUsageRequest, + authoritative: (Option, Option), +) -> Result { + merge_authoritative_remote_fact( + "remote connection", + &mut request.remote_connection_id, + authoritative.0, + )?; + merge_authoritative_remote_fact( + "remote SSH host", + &mut request.remote_ssh_host, + authoritative.1, + )?; + Ok(request) +} + fn validated_session_summary( sessions: &[AgentSessionSummary], session_id: &str, @@ -86,6 +141,16 @@ pub(crate) enum SessionMigrationNotice { }, } +#[derive(Debug)] +pub(crate) struct CliSessionRestoreSnapshot { + pub(crate) summary: AgentSessionSummary, + pub(crate) workspace_binding: AgentSessionWorkspaceBinding, + pub(crate) migration_notices: Vec, + pub(crate) transcript: SessionTranscript, + pub(crate) active_turn_id: Option, + pub(crate) pending_user_inputs: Vec, +} + impl SessionMigrationNotice { pub(crate) fn user_message(&self) -> String { let (setting, previous_id, restored_id) = match self { @@ -143,7 +208,7 @@ impl fmt::Display for SessionOperationError { impl std::error::Error for SessionOperationError {} impl SessionOperationError { - fn runtime(error: RuntimeError) -> Self { + pub(crate) fn runtime(error: RuntimeError) -> Self { let outcome_unknown = matches!( &error, RuntimeError::Port(port_error) @@ -156,17 +221,7 @@ impl SessionOperationError { } fn shared(error: RuntimeIpcClientError) -> Self { - let outcome_unknown = matches!( - &error, - RuntimeIpcClientError::Remote(remote) - if remote.code == RuntimeIpcErrorCode::OutcomeUnknown - ) || matches!( - &error, - RuntimeIpcClientError::Timeout - | RuntimeIpcClientError::Disconnected - | RuntimeIpcClientError::UnexpectedResponse - | RuntimeIpcClientError::Io(_) - ); + let outcome_unknown = shared_side_effect_outcome_is_unknown(&error); Self { message: error.to_string(), outcome_unknown, @@ -204,12 +259,60 @@ impl SessionOperationError { } } +fn shared_side_effect_outcome_is_unknown(error: &RuntimeIpcClientError) -> bool { + matches!( + error, + RuntimeIpcClientError::Remote(remote) + if remote.code == RuntimeIpcErrorCode::OutcomeUnknown + ) || matches!( + error, + RuntimeIpcClientError::Timeout + | RuntimeIpcClientError::Disconnected + | RuntimeIpcClientError::UnexpectedResponse + | RuntimeIpcClientError::Io(_) + ) +} + +pub(crate) fn side_effect_error_outcome_is_unknown(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(shared_side_effect_outcome_is_unknown) +} + +fn should_clear_turn_after_submission_error(error: &anyhow::Error) -> bool { + !error + .downcast_ref::() + .is_some_and(SessionOperationError::outcome_unknown) +} + +fn runtime_side_effect_error(error: RuntimeError) -> anyhow::Error { + anyhow::Error::new(SessionOperationError::runtime(error)) +} + +fn validated_accepted_turn( + operation: &str, + expected_session_id: &str, + expected_turn_id: &str, + accepted_session_id: String, + accepted_turn_id: String, +) -> Result { + if accepted_session_id == expected_session_id && accepted_turn_id == expected_turn_id { + return Ok(accepted_turn_id); + } + Err(anyhow::Error::new(SessionOperationError::unexpected( + anyhow::anyhow!( + "Runtime returned an unexpected identity for {operation}: expected session={expected_session_id}, turn={expected_turn_id}; received session={accepted_session_id}, turn={accepted_turn_id}" + ), + ))) +} + #[derive(Clone, Debug)] struct CliWorkspacePaths { project: Option, execution: Option, execution_target: Option, - remote: bool, + remote_connection_id: Option, + remote_ssh_host: Option, } impl CliWorkspacePaths { @@ -218,7 +321,8 @@ impl CliWorkspacePaths { project: workspace_path.clone(), execution: workspace_path, execution_target: None, - remote: false, + remote_connection_id: None, + remote_ssh_host: None, } } @@ -249,7 +353,8 @@ impl CliWorkspacePaths { self.execution = Some(execution); self.project = Some(project); self.execution_target = binding.execution_target.clone(); - self.remote = binding.remote_connection_id.is_some() || binding.remote_ssh_host.is_some(); + self.remote_connection_id = binding.remote_connection_id.clone(); + self.remote_ssh_host = binding.remote_ssh_host.clone(); } fn reset_execution_to_project(&mut self) -> PathBuf { @@ -258,12 +363,20 @@ impl CliWorkspacePaths { self.execution_target = Some(SessionExecutionTarget::local( project.to_string_lossy().to_string(), )); - self.remote = false; + self.remote_connection_id = None; + self.remote_ssh_host = None; project } + pub(crate) fn remote_scope(&self) -> (Option, Option) { + ( + self.remote_connection_id.clone(), + self.remote_ssh_host.clone(), + ) + } + fn workspace_diff_unavailable_reason(&self) -> Option<&'static str> { - if self.remote { + if self.remote_connection_id.is_some() || self.remote_ssh_host.is_some() { return Some("Workspace diff is unavailable for remote Sessions"); } let execution = self.execution(); @@ -285,10 +398,12 @@ fn same_workspace_location(left: &Path, right: &Path) -> bool { .is_some_and(|(left, right)| left == right) } -/// CLI-owned client for the portable Agent Runtime SDK. -/// Stateless regarding agent_type; callers pass it per call. -pub(crate) struct ExecAgentRuntimeClient { - backend: CliAgentRuntimeBackend, +/// CLI-owned stateful client for the portable Agent Runtime SDK. +/// +/// Interactive and non-interactive surfaces share this owner; presentation +/// clients delegate Runtime behavior instead of rebuilding session state. +pub(crate) struct CliAgentRuntimeClient { + backend: TuiRuntimePort, approval_policy: Arc>, workspace_paths: Arc>, /// Session ID — uses Mutex for interior mutability @@ -299,13 +414,16 @@ pub(crate) struct ExecAgentRuntimeClient { shared_permission_events: Option>, shared_pending_permissions: Arc>>, + shared_event_bridge: Option>, } -enum CliAgentRuntimeBackend { +enum TuiRuntimePort { Embedded(AgentRuntime), Shared(RuntimeIpcClient), } +pub(crate) type ExecAgentRuntimeClient = CliAgentRuntimeClient; + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CliAgentMode { pub(crate) id: String, @@ -319,7 +437,7 @@ type SharedBroadcast = Arc>>>; impl ExecAgentRuntimeClient { pub(crate) fn new(runtime: &CliRuntimeContext, workspace_path: Option) -> Self { Self { - backend: CliAgentRuntimeBackend::Embedded(runtime.agent_runtime().clone()), + backend: TuiRuntimePort::Embedded(runtime.agent_runtime().clone()), approval_policy: Arc::new(RwLock::new(runtime.approval_policy())), workspace_paths: Arc::new(RwLock::new(CliWorkspacePaths::new(workspace_path))), session_id: Arc::new(Mutex::new(None)), @@ -327,9 +445,14 @@ impl ExecAgentRuntimeClient { shared_agent_events: None, shared_permission_events: None, shared_pending_permissions: Arc::new(RwLock::new(HashMap::new())), + shared_event_bridge: None, } } + pub(crate) fn new_direct(runtime: &CliRuntimeContext, workspace_path: Option) -> Self { + Self::new(runtime, workspace_path) + } + pub(crate) fn new_shared(client: RuntimeIpcClient, workspace_path: Option) -> Self { let (agent_sender, _) = broadcast::channel(256); let (permission_sender, _) = broadcast::channel(64); @@ -337,7 +460,7 @@ impl ExecAgentRuntimeClient { let shared_permission_events = Arc::new(RwLock::new(Some(permission_sender.clone()))); let shared_pending_permissions = Arc::new(RwLock::new(HashMap::new())); let session_id = Arc::new(Mutex::new(None)); - spawn_shared_event_bridge( + let shared_event_bridge = spawn_shared_event_bridge( client.subscribe_events(), agent_sender, permission_sender, @@ -346,7 +469,7 @@ impl ExecAgentRuntimeClient { shared_pending_permissions.clone(), ); Self { - backend: CliAgentRuntimeBackend::Shared(client), + backend: TuiRuntimePort::Shared(client), approval_policy: Arc::new(RwLock::new(CliApprovalPolicy::Ask)), workspace_paths: Arc::new(RwLock::new(CliWorkspacePaths::new(workspace_path))), session_id, @@ -354,11 +477,12 @@ impl ExecAgentRuntimeClient { shared_agent_events: Some(shared_agent_events), shared_permission_events: Some(shared_permission_events), shared_pending_permissions, + shared_event_bridge: Some(shared_event_bridge), } } pub(crate) fn is_shared(&self) -> bool { - matches!(self.backend, CliAgentRuntimeBackend::Shared(_)) + matches!(self.backend, TuiRuntimePort::Shared(_)) } /// Read the main-agent catalog from the execution owner. Embedded and @@ -366,7 +490,7 @@ impl ExecAgentRuntimeClient { /// consults the controller process's local registry. pub(crate) async fn available_agent_modes(&self) -> Result> { match &self.backend { - CliAgentRuntimeBackend::Embedded(_) => { + TuiRuntimePort::Embedded(_) => { let workspace = self.workspace_path_buf(); if let Err(error) = bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( @@ -390,7 +514,7 @@ impl ExecAgentRuntimeClient { }) .collect()) } - CliAgentRuntimeBackend::Shared(client) => { + TuiRuntimePort::Shared(client) => { let session_id = self.session_id.lock().await.clone(); match client .request(RuntimeIpcOperation::ListAgentModes { session_id }) @@ -415,8 +539,8 @@ impl ExecAgentRuntimeClient { fn embedded_runtime(&self, operation: &str) -> Result<&AgentRuntime> { match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => Ok(runtime), - CliAgentRuntimeBackend::Shared(_) => Err(anyhow::anyhow!( + TuiRuntimePort::Embedded(runtime) => Ok(runtime), + TuiRuntimePort::Shared(_) => Err(anyhow::anyhow!( "{operation} is not available in the first Shared TUI slice; use default Embedded `bitfun chat`" )), } @@ -424,8 +548,8 @@ impl ExecAgentRuntimeClient { pub(crate) fn subscribe_events(&self) -> std::result::Result { match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime.subscribe_events(), - CliAgentRuntimeBackend::Shared(_) => shared_receiver( + TuiRuntimePort::Embedded(runtime) => runtime.subscribe_events(), + TuiRuntimePort::Shared(_) => shared_receiver( self.shared_agent_events.as_ref(), "Shared Runtime agent event stream is unavailable", ), @@ -436,8 +560,8 @@ impl ExecAgentRuntimeClient { &self, ) -> std::result::Result { match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime.subscribe_permission_requests(), - CliAgentRuntimeBackend::Shared(_) => shared_receiver( + TuiRuntimePort::Embedded(runtime) => runtime.subscribe_permission_requests(), + TuiRuntimePort::Shared(_) => shared_receiver( self.shared_permission_events.as_ref(), "Shared Runtime permission event stream is unavailable", ), @@ -448,8 +572,8 @@ impl ExecAgentRuntimeClient { &self, ) -> std::result::Result, RuntimeError> { match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime.pending_permission_requests(), - CliAgentRuntimeBackend::Shared(_) => Ok(self + TuiRuntimePort::Embedded(runtime) => runtime.pending_permission_requests(), + TuiRuntimePort::Shared(_) => Ok(self .shared_pending_permissions .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -465,20 +589,23 @@ impl ExecAgentRuntimeClient { reply: PermissionReply, ) -> Result<()> { match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .respond_permission(request_id, reply) .await - .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => { + .map_err(SessionOperationError::runtime) + .map_err(anyhow::Error::new), + TuiRuntimePort::Shared(client) => { let session_id = self.require_session_id().await?; - expect_unit( + expect_side_effect_unit( client .request(RuntimeIpcOperation::RespondPermission { session_id, request_id: request_id.to_string(), reply, }) - .await?, + .await + .map_err(SessionOperationError::shared) + .map_err(anyhow::Error::new)?, "respond_permission", ) } @@ -538,6 +665,18 @@ impl ExecAgentRuntimeClient { .clone() } + fn remote_scope(&self) -> (Option, Option) { + self.workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remote_scope() + } + + pub(crate) fn is_remote_workspace(&self) -> bool { + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); + remote_connection_id.is_some() || remote_ssh_host.is_some() + } + fn reset_execution_to_project_workspace(&self) -> PathBuf { self.workspace_paths .write() @@ -553,17 +692,18 @@ impl ExecAgentRuntimeClient { &self, workspace_path: &Path, ) -> Result> { + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); let request = AgentSessionListRequest { workspace_path: workspace_path.to_string_lossy().to_string(), - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, }; match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .list_sessions(request) .await .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => match client + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::ListSessions { request }) .await? { @@ -582,18 +722,19 @@ impl ExecAgentRuntimeClient { &self, root_session_id: &str, ) -> Result> { + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); let request = AgentSessionLineageRequest { workspace_path: self.current_workspace_path().to_string_lossy().into_owned(), anchor_session_id: root_session_id.to_string(), - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, }; match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .get_session_lineage(request) .await .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => match client + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::GetSessionLineage { request }) .await? { @@ -609,20 +750,21 @@ impl ExecAgentRuntimeClient { session_id: &str, required_settled_turn_ids: &[String], ) -> std::result::Result { + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); let request = AgentSessionLineageTranscriptRequest { workspace_path: self.current_workspace_path().to_string_lossy().into_owned(), root_session_id: root_session_id.to_string(), session_id: session_id.to_string(), required_settled_turn_ids: required_settled_turn_ids.to_vec(), - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, }; match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .read_lineage_session_transcript(request) .await .map_err(SessionOperationError::runtime), - CliAgentRuntimeBackend::Shared(client) => match client + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::InspectLineageSession { request }) .await .map_err(SessionOperationError::read_only_shared)? @@ -643,6 +785,7 @@ impl ExecAgentRuntimeClient { session_id: &str, expected_active_turn_id: &str, ) -> Result { + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); let request = AgentSessionLineageCancellationRequest { workspace_path: self.current_workspace_path().to_string_lossy().into_owned(), root_session_id: root_session_id.to_string(), @@ -651,15 +794,15 @@ impl ExecAgentRuntimeClient { source: Some(AgentSubmissionSource::Cli), reason: Some("user_cancelled".to_string()), wait_timeout_ms: Some(5_000), - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, }; match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .cancel_lineage_session(request) .await .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => match client + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::CancelLineageSession { request }) .await? { @@ -672,52 +815,51 @@ impl ExecAgentRuntimeClient { pub(crate) async fn restore_session_in_current_workspace( &self, session_id: &str, - ) -> Result<( - AgentSessionSummary, - AgentSessionWorkspaceBinding, - Vec, - SessionTranscript, - )> { + ) -> Result { tracing::info!("Restoring session: {}", session_id); let project_workspace = self.current_workspace_path(); let sessions = self.list_sessions_in_workspace(&project_workspace).await?; let previous_summary = validated_session_summary(&sessions, session_id, &project_workspace)?; - - let (restored, transcript, shared_pending, shared_workspace_binding) = match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); + + let ( + restored, + restored_turn_id, + transcript, + pending_user_inputs, + shared_pending, + shared_workspace_binding, + ) = match &self.backend { + TuiRuntimePort::Embedded(runtime) => { let restored = runtime .restore_session(AgentSessionRestoreRequest { workspace_path: project_workspace.to_string_lossy().to_string(), session_id: session_id.to_string(), include_internal: false, - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, }) .await - .map(|restored| restored.session) .map_err(anyhow::Error::new) .map_err(with_session_conflict_help)?; - let transcript = runtime - .read_session_transcript(SessionTranscriptRequest { - session_id: session_id.to_string(), - turn_id: None, - }) - .await - .unwrap_or_else(|error| { - tracing::warn!( - "Failed to read Embedded session transcript: {}", - error.into_message() - ); - SessionTranscript { - session_id: session_id.to_string(), - messages: Vec::new(), - } - }); - (restored, transcript, None, None) + let restored_turn_id = match &restored.state { + SessionState::Processing { + current_turn_id, .. + } => Some(current_turn_id.clone()), + SessionState::Idle | SessionState::Error { .. } => None, + }; + ( + restored.session, + restored_turn_id, + restored.transcript, + restored.pending_user_inputs, + None, + None, + ) } - CliAgentRuntimeBackend::Shared(client) => match client + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::RestoreSession { request: RuntimeSessionRestoreRequest { workspace_path: project_workspace.to_string_lossy().to_string(), @@ -729,20 +871,37 @@ impl ExecAgentRuntimeClient { { RuntimeIpcOperationResult::SessionRestored { session, + state, workspace_binding, transcript, pending_permissions, - .. - } => ( - session, - transcript, - Some(pending_permissions), - Some(workspace_binding), - ), + pending_user_inputs, + } => { + let restored_turn_id = match state { + RuntimeSessionState::Processing { + current_turn_id, .. + } => Some(current_turn_id), + RuntimeSessionState::Idle | RuntimeSessionState::Error { .. } => None, + }; + ( + session, + restored_turn_id, + transcript, + pending_user_inputs, + Some(pending_permissions), + Some(workspace_binding), + ) + } _ => return Err(unexpected_shared_result("restore_session")), }, }; + if restored.session_id != session_id || transcript.session_id != session_id { + return Err(anyhow::anyhow!( + "Runtime restored an unexpected Session identity" + )); + } + let binding = if let Some(binding) = shared_workspace_binding { self.set_workspace_binding(&binding); binding @@ -753,7 +912,7 @@ impl ExecAgentRuntimeClient { let mut session_id_guard = self.session_id.lock().await; let mut turn_id_guard = self.current_turn_id.lock().await; *session_id_guard = Some(session_id.to_string()); - *turn_id_guard = None; + *turn_id_guard = restored_turn_id.clone(); drop(session_id_guard); drop(turn_id_guard); if let Some(requests) = shared_pending { @@ -770,7 +929,14 @@ impl ExecAgentRuntimeClient { } let migration_notices = session_migration_notices(&previous_summary, &restored); - Ok((restored, binding, migration_notices, transcript)) + Ok(CliSessionRestoreSnapshot { + summary: restored, + workspace_binding: binding, + migration_notices, + transcript, + active_turn_id: restored_turn_id, + pending_user_inputs, + }) } async fn resolve_session_workspace_binding( @@ -780,14 +946,22 @@ impl ExecAgentRuntimeClient { ) -> Result { let fallback_project = fallback_project_workspace.to_string_lossy().to_string(); let resolved = match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .resolve_session_workspace_binding(AgentSessionWorkspaceRequest { session_id: session_id.to_string(), }) .await .map_err(|error| anyhow::anyhow!(error.into_message()))?, - CliAgentRuntimeBackend::Shared(_) => None, + TuiRuntimePort::Shared(_) => None, }; + if resolved.is_none() { + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); + if remote_connection_id.is_some() || remote_ssh_host.is_some() { + return Err(anyhow::anyhow!( + "Runtime did not return an authoritative workspace binding for remote Session {session_id}" + )); + } + } let binding = resolved.unwrap_or_else(|| AgentSessionWorkspaceBinding { workspace_id: None, workspace_path: fallback_project.clone(), @@ -810,21 +984,35 @@ impl ExecAgentRuntimeClient { .await } + async fn workspace_binding_after_session_creation( + &self, + session_id: &str, + ) -> Result { + self.session_workspace_binding(session_id) + .await + .map_err(|error| { + anyhow::Error::new(SessionOperationError::unexpected(anyhow::anyhow!( + "Session creation completed, but resolving its workspace binding failed: {error}. Inspect authoritative Session state before retrying" + ))) + }) + } + pub(crate) async fn delete_session( &self, session_id: &str, ) -> std::result::Result<(), SessionOperationError> { + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .delete_session(AgentSessionDeleteRequest { workspace_path: self.project_workspace_path_string(), session_id: session_id.to_string(), - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, }) .await .map_err(SessionOperationError::runtime), - CliAgentRuntimeBackend::Shared(client) => { + TuiRuntimePort::Shared(client) => { let result = client .request(RuntimeIpcOperation::DeleteSession { session_id: session_id.to_string(), @@ -846,11 +1034,11 @@ impl ExecAgentRuntimeClient { model_id: model_id.to_string(), }; match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .update_session_model(request) .await .map_err(SessionOperationError::runtime), - CliAgentRuntimeBackend::Shared(client) => { + TuiRuntimePort::Shared(client) => { let result = client .request(RuntimeIpcOperation::UpdateSessionModel { request }) .await @@ -866,21 +1054,22 @@ impl ExecAgentRuntimeClient { session_id: &str, session_name: &str, ) -> std::result::Result<(), SessionOperationError> { + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { + TuiRuntimePort::Embedded(runtime) => { let request = AgentSessionRenameRequest { workspace_path: self.project_workspace_path_string(), session_id: session_id.to_string(), session_name: session_name.to_string(), - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, }; runtime .rename_session(request) .await .map_err(SessionOperationError::runtime) } - CliAgentRuntimeBackend::Shared(client) => { + TuiRuntimePort::Shared(client) => { let result = client .request(RuntimeIpcOperation::RenameSession { request: RuntimeSessionRenameRequest { @@ -905,11 +1094,11 @@ impl ExecAgentRuntimeClient { mode_id: mode_id.to_string(), }; match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .update_session_mode(request) .await .map_err(SessionOperationError::runtime), - CliAgentRuntimeBackend::Shared(client) => { + TuiRuntimePort::Shared(client) => { let result = client .request(RuntimeIpcOperation::UpdateSessionMode { request }) .await @@ -924,15 +1113,16 @@ impl ExecAgentRuntimeClient { &self, source_session_id: &str, ) -> Result { + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); self.embedded_runtime("forking sessions")? .fork_session(AgentSessionForkRequest { workspace_path: self.project_workspace_path_string(), source_session_id: source_session_id.to_string(), - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, }) .await - .map_err(|error| anyhow::anyhow!(error.into_message())) + .map_err(runtime_side_effect_error) } pub(crate) async fn fork_current_session( @@ -945,8 +1135,9 @@ impl ExecAgentRuntimeClient { )> { let source_session_id = self.require_session_id().await?; let workspace_path = self.project_workspace_path_string(); + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); let (session, transcript, shared_workspace_binding) = match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { + TuiRuntimePort::Embedded(runtime) => { let forked = match before_turn_id { Some(source_turn_id) => { runtime @@ -954,8 +1145,8 @@ impl ExecAgentRuntimeClient { workspace_path: workspace_path.clone(), source_session_id, source_turn_id: source_turn_id.to_string(), - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id: remote_connection_id.clone(), + remote_ssh_host: remote_ssh_host.clone(), }) .await } @@ -964,47 +1155,63 @@ impl ExecAgentRuntimeClient { .fork_session(AgentSessionForkRequest { workspace_path: workspace_path.clone(), source_session_id, - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id: remote_connection_id.clone(), + remote_ssh_host: remote_ssh_host.clone(), }) .await } } - .map_err(|error| anyhow::anyhow!(error.into_message()))?; + .map_err(runtime_side_effect_error)?; let restored = runtime .restore_session(AgentSessionRestoreRequest { workspace_path: workspace_path.clone(), session_id: forked.session_id.clone(), include_internal: false, - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, }) .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?; + .map_err(|error| { + anyhow::Error::new(SessionOperationError::unexpected(anyhow::anyhow!( + "Session fork completed, but restoring the new Session failed: {}", + error.into_message() + ))) + })?; let transcript = runtime .read_session_transcript(SessionTranscriptRequest { session_id: forked.session_id, turn_id: None, }) .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?; + .map_err(|error| { + anyhow::Error::new(SessionOperationError::unexpected(anyhow::anyhow!( + "Session fork completed, but reading the new transcript failed: {}", + error.into_message() + ))) + })?; (restored.session, transcript, None) } - CliAgentRuntimeBackend::Shared(client) => match client + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::ForkSession { request: RuntimeSessionForkRequest { session_id: source_session_id, before_turn_id: before_turn_id.map(str::to_string), }, }) - .await? + .await + .map_err(SessionOperationError::shared) + .map_err(anyhow::Error::new)? { RuntimeIpcOperationResult::SessionForked { session, workspace_binding, transcript, } => (session, transcript, Some(workspace_binding)), - _ => return Err(unexpected_shared_result("fork_session")), + _ => { + return Err(anyhow::Error::new(SessionOperationError::unexpected( + unexpected_shared_result("fork_session"), + ))) + } }, }; @@ -1013,7 +1220,12 @@ impl ExecAgentRuntimeClient { binding } else { self.resolve_session_workspace_binding(&session.session_id, Path::new(&workspace_path)) - .await? + .await + .map_err(|error| { + anyhow::Error::new(SessionOperationError::unexpected(anyhow::anyhow!( + "Session fork completed, but resolving its workspace binding failed: {error}" + ))) + })? }; *self.session_id.lock().await = Some(session.session_id.clone()); *self.current_turn_id.lock().await = None; @@ -1029,40 +1241,49 @@ impl ExecAgentRuntimeClient { undo: bool, ) -> Result { let session_id = self.require_session_id().await?; + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); let request = AgentSessionRevertRequest { workspace_path: self.project_workspace_path_string(), session_id: session_id.clone(), - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, }; let locally_active_turn_id = self.current_turn_id.lock().await.clone(); let mut reverted = match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => if undo { + TuiRuntimePort::Embedded(runtime) => if undo { runtime.undo_session(request).await } else { runtime.redo_session(request).await } - .map_err(|error| anyhow::anyhow!(error.into_message()))?, - CliAgentRuntimeBackend::Shared(client) => { + .map_err(runtime_side_effect_error)?, + TuiRuntimePort::Shared(client) => { let operation = if undo { RuntimeIpcOperation::UndoSession { request } } else { RuntimeIpcOperation::RedoSession { request } }; - match client.request(operation).await? { + match client + .request(operation) + .await + .map_err(|error| anyhow::Error::new(SessionOperationError::shared(error)))? + { RuntimeIpcOperationResult::SessionReverted { revert } if revert.session_id == session_id => { revert } - _ => return Err(unexpected_shared_result("revert_session")), + _ => { + return Err(anyhow::Error::new(SessionOperationError::unexpected( + unexpected_shared_result("revert_session"), + ))) + } } } }; if reverted.session_id != session_id { - return Err(anyhow::anyhow!( - "Runtime reverted an unexpected session identity" - )); + return Err(anyhow::Error::new(SessionOperationError::unexpected( + anyhow::anyhow!("Runtime reverted an unexpected session identity"), + ))); } if let Some(turn_id) = locally_active_turn_id { if !reverted.retired_turn_ids.contains(&turn_id) { @@ -1083,11 +1304,11 @@ impl ExecAgentRuntimeClient { return Err(anyhow::anyhow!(reason)); } match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .workspace_diff() .await .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => { + TuiRuntimePort::Shared(client) => { match client.request(RuntimeIpcOperation::WorkspaceDiff).await? { RuntimeIpcOperationResult::WorkspaceDiff { snapshot } => Ok(snapshot), _ => Err(unexpected_shared_result("workspace_diff")), @@ -1096,14 +1317,41 @@ impl ExecAgentRuntimeClient { } } + pub(crate) async fn reload_context(&self, request: AgentContextReloadRequest) -> Result<()> { + match &self.backend { + TuiRuntimePort::Embedded(runtime) => runtime + .reload_context(request) + .await + .map_err(|error| anyhow::anyhow!(error.into_message())), + TuiRuntimePort::Shared(client) => expect_unit( + client + .request(RuntimeIpcOperation::ReloadSessionContext { request }) + .await?, + "reload_session_context", + ), + } + } + pub(crate) async fn generate_session_usage_report( &self, request: AgentSessionUsageRequest, ) -> Result { - self.embedded_runtime("generating session usage")? - .generate_session_usage(request) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())) + let request = merge_session_usage_remote_scope(request, self.remote_scope())?; + match &self.backend { + TuiRuntimePort::Embedded(runtime) => runtime + .generate_session_usage(request) + .await + .map_err(|error| anyhow::anyhow!(error.into_message())), + TuiRuntimePort::Shared(client) => { + match client + .request(RuntimeIpcOperation::SessionUsage { request }) + .await? + { + RuntimeIpcOperationResult::SessionUsage { usage } => Ok(usage), + _ => Err(unexpected_shared_result("session_usage")), + } + } + } } pub(crate) async fn wait_for_turn_settlement( @@ -1112,20 +1360,24 @@ impl ExecAgentRuntimeClient { turn_id: &str, wait_timeout_ms: u64, ) -> std::result::Result<(), RuntimeError> { + let request = AgentTurnSettlementRequest { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + wait_timeout_ms, + }; match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { - runtime - .wait_for_turn_settlement(AgentTurnSettlementRequest { - session_id: session_id.to_string(), - turn_id: turn_id.to_string(), - wait_timeout_ms, - }) - .await - } - CliAgentRuntimeBackend::Shared(_) => Err(RuntimeError::Port(PortError::new( - PortErrorKind::NotAvailable, - "turn settlement waiting is not available in the first Shared TUI slice", - ))), + TuiRuntimePort::Embedded(runtime) => runtime.wait_for_turn_settlement(request).await, + TuiRuntimePort::Shared(client) => match client + .request(RuntimeIpcOperation::WaitForSettlement { request }) + .await + .map_err(shared_runtime_error)? + { + RuntimeIpcOperationResult::Unit => Ok(()), + _ => Err(RuntimeError::Port(PortError::new( + PortErrorKind::Backend, + "Shared Runtime returned an unexpected result for wait_for_settlement", + ))), + }, } } @@ -1147,14 +1399,15 @@ impl ExecAgentRuntimeClient { let runtime = self.embedded_runtime("recreating sessions with fixed identifiers")?; let mut session_name = Self::build_default_session_name(); let mut effective_agent_type = agent_type.to_string(); + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); let workspace = self.workspace_path_buf(); let project_workspace = self.project_workspace_path_buf(); if let Ok(sessions) = runtime .list_sessions(AgentSessionListRequest { workspace_path: project_workspace.to_string_lossy().to_string(), - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id: remote_connection_id.clone(), + remote_ssh_host: remote_ssh_host.clone(), }) .await { @@ -1174,8 +1427,8 @@ impl ExecAgentRuntimeClient { project_workspace_path: Some(project_workspace.to_string_lossy().to_string()), execution_target: self.execution_target(), workspace_id: None, - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, model_id: None, metadata: serde_json::Map::new(), }, @@ -1191,13 +1444,14 @@ impl ExecAgentRuntimeClient { async fn ensure_backend_session_alive(&self, session_id: &str, agent_type: &str) -> Result<()> { let runtime = self.embedded_runtime("recovering Embedded sessions")?; let project_workspace = self.project_workspace_path_buf(); + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); match runtime .restore_session(AgentSessionRestoreRequest { workspace_path: project_workspace.to_string_lossy().to_string(), session_id: session_id.to_string(), include_internal: false, - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, }) .await { @@ -1231,6 +1485,7 @@ impl ExecAgentRuntimeClient { let mut session_id_guard = self.session_id.lock().await; let workspace_path = self.workspace_path_string(); let project_workspace_path = self.project_workspace_path_string(); + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); let session = runtime .create_session_with_id( @@ -1242,8 +1497,8 @@ impl ExecAgentRuntimeClient { project_workspace_path: Some(project_workspace_path), execution_target: self.execution_target(), workspace_id: None, - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, model_id: None, metadata: serde_json::Map::new(), }, @@ -1265,6 +1520,17 @@ impl ExecAgentRuntimeClient { self.ensure_session_with_model(agent_type, None).await } + pub(crate) async fn ensure_session_with_workspace_binding( + &self, + agent_type: &str, + ) -> Result<(String, AgentSessionWorkspaceBinding)> { + let session_id = self.ensure_session(agent_type).await?; + let binding = self + .workspace_binding_after_session_creation(&session_id) + .await?; + Ok((session_id, binding)) + } + /// Ensure the startup Session exists, preserving an explicit user model /// selection in the authoritative creation request. pub(crate) async fn ensure_session_with_model( @@ -1278,6 +1544,7 @@ impl ExecAgentRuntimeClient { return Ok(id.clone()); } + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); let request = AgentSessionCreateRequest { session_name: Self::build_default_session_name(), agent_type: agent_type.to_string(), @@ -1285,22 +1552,28 @@ impl ExecAgentRuntimeClient { project_workspace_path: None, execution_target: None, workspace_id: None, - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, model_id, metadata: serde_json::Map::new(), }; let session = match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .create_session(request) .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?, - CliAgentRuntimeBackend::Shared(client) => match client + .map_err(runtime_side_effect_error)?, + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::CreateSession { request }) - .await? + .await + .map_err(SessionOperationError::shared) + .map_err(anyhow::Error::new)? { RuntimeIpcOperationResult::SessionCreated { session } => session, - _ => return Err(unexpected_shared_result("create_session")), + _ => { + return Err(anyhow::Error::new(SessionOperationError::unexpected( + unexpected_shared_result("create_session"), + ))) + } }, }; @@ -1324,34 +1597,47 @@ impl ExecAgentRuntimeClient { let submission: Result = async { match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { + TuiRuntimePort::Embedded(runtime) => { let accepted = runtime .start_session_compaction(request) .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?; + .map_err(SessionOperationError::runtime) + .map_err(anyhow::Error::new)?; if accepted.session_id != session_id || accepted.turn_id != turn_id { - return Err(anyhow::anyhow!( - "Runtime accepted manual compaction with an unexpected identity" - )); + return Err(anyhow::Error::new(SessionOperationError::unexpected( + anyhow::anyhow!( + "Runtime accepted manual compaction with an unexpected identity" + ), + ))); } Ok(accepted.turn_id) } - CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::CompactSession { request }) - .await? - { - RuntimeIpcOperationResult::TurnAccepted { - session_id: accepted_session, - turn_id: accepted_turn, - } if accepted_session == session_id && accepted_turn == turn_id => { - Ok(accepted_turn) + TuiRuntimePort::Shared(client) => { + match client + .request(RuntimeIpcOperation::CompactSession { request }) + .await + .map_err(SessionOperationError::shared) + .map_err(anyhow::Error::new)? + { + RuntimeIpcOperationResult::TurnAccepted { + session_id: accepted_session, + turn_id: accepted_turn, + } if accepted_session == session_id && accepted_turn == turn_id => { + Ok(accepted_turn) + } + _ => Err(anyhow::Error::new(SessionOperationError::unexpected( + unexpected_shared_result("compact_session"), + ))), } - _ => return Err(unexpected_shared_result("compact_session")), - }, + } } } .await; - if submission.is_err() { + if submission + .as_ref() + .err() + .is_some_and(should_clear_turn_after_submission_error) + { *self.current_turn_id.lock().await = None; } submission @@ -1442,6 +1728,7 @@ impl ExecAgentRuntimeClient { let mut metadata = approval_metadata(self.approval_policy()); put_agent_workspace_references(&mut metadata, &workspace_references) .map_err(|error| anyhow::anyhow!(error.message))?; + let (remote_connection_id, remote_ssh_host) = self.remote_scope(); let request = AgentDialogTurnRequest { session_id: session_id.clone(), message: message.clone(), @@ -1452,8 +1739,8 @@ impl ExecAgentRuntimeClient { // Dialog submission uses this path to locate persisted session // state. Execution still comes from the session's resolved binding. workspace_path: Some(self.project_workspace_path_string()), - remote_connection_id: None, - remote_ssh_host: None, + remote_connection_id, + remote_ssh_host, policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), reply_route: None, prepended_reminders: Vec::new(), @@ -1462,12 +1749,11 @@ impl ExecAgentRuntimeClient { }; let submission: Result = async { match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { - let start_result = runtime.submit_dialog_turn(request.clone()).await; - if let Err(err) = start_result { - let session_not_found = Self::is_session_not_found_error(&err); - let error_message = err.into_message(); - if session_not_found { + TuiRuntimePort::Embedded(runtime) => { + let outcome = match runtime.submit_dialog_turn(request.clone()).await { + Ok(outcome) => outcome, + Err(error) if Self::is_session_not_found_error(&error) => { + let error_message = error.into_message(); tracing::warn!( "Session missing when starting turn, attempting recovery and retry: session_id={}, error={}", session_id, @@ -1478,30 +1764,58 @@ impl ExecAgentRuntimeClient { runtime .submit_dialog_turn(request) .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?; - } else { - return Err(anyhow::anyhow!(error_message)); + .map_err(runtime_side_effect_error)? } - } - Ok(turn_id) + Err(error) => { + return Err(anyhow::Error::new(SessionOperationError::runtime(error))) + } + }; + let (accepted_session_id, accepted_turn_id) = match outcome { + DialogSubmitOutcome::Started { + session_id, + turn_id, + } + | DialogSubmitOutcome::Queued { + session_id, + turn_id, + } => (session_id, turn_id), + }; + validated_accepted_turn( + "submit_dialog_turn", + &session_id, + &turn_id, + accepted_session_id, + accepted_turn_id, + ) } - CliAgentRuntimeBackend::Shared(client) => match client + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::SubmitTurn { request }) - .await? + .await + .map_err(|error| anyhow::Error::new(SessionOperationError::shared(error)))? { RuntimeIpcOperationResult::TurnAccepted { session_id: accepted_session, turn_id: accepted_turn, - } if accepted_session == session_id => { - *self.current_turn_id.lock().await = Some(accepted_turn.clone()); - Ok(accepted_turn) - } - _ => Err(unexpected_shared_result("submit_turn")), + } => validated_accepted_turn( + "submit_turn", + &session_id, + &turn_id, + accepted_session, + accepted_turn, + ), + _ => Err(anyhow::Error::new(SessionOperationError::unexpected( + unexpected_shared_result("submit_turn"), + ))), }, } } .await; - if submission.is_err() { + let outcome_unknown = submission + .as_ref() + .err() + .and_then(|error| error.downcast_ref::()) + .is_some_and(SessionOperationError::outcome_unknown); + if submission.is_err() && !outcome_unknown { *self.current_turn_id.lock().await = None; } submission @@ -1538,23 +1852,26 @@ impl ExecAgentRuntimeClient { }; match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => match runtime + TuiRuntimePort::Embedded(runtime) => match runtime .steer_dialog_turn(request) .await - .map_err(|error| anyhow::anyhow!(error.into_message()))? + .map_err(runtime_side_effect_error)? { DialogSteerOutcome::Buffered { steering_id, .. } => Ok(steering_id), }, - CliAgentRuntimeBackend::Shared(client) => match client + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::SteerTurn { request }) - .await? + .await + .map_err(|error| anyhow::Error::new(SessionOperationError::shared(error)))? { RuntimeIpcOperationResult::TurnSteered { session_id: steered_session, turn_id: steered_turn, steering_id, } if steered_session == session_id && steered_turn == turn_id => Ok(steering_id), - _ => Err(unexpected_shared_result("steer_turn")), + _ => Err(anyhow::Error::new(SessionOperationError::unexpected( + unexpected_shared_result("steer_turn"), + ))), }, } } @@ -1575,7 +1892,7 @@ impl ExecAgentRuntimeClient { let submission: Result = async { match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { + TuiRuntimePort::Embedded(runtime) => { let accepted = match runtime.run_user_shell_command(request.clone()).await { Ok(accepted) => accepted, Err(error) if Self::is_session_not_found_error(&error) => { @@ -1588,19 +1905,23 @@ impl ExecAgentRuntimeClient { runtime .run_user_shell_command(request) .await - .map_err(|error| anyhow::anyhow!(error.into_message()))? + .map_err(runtime_side_effect_error)? + } + Err(error) => { + return Err(anyhow::Error::new(SessionOperationError::runtime(error))) } - Err(error) => return Err(anyhow::anyhow!(error.into_message())), }; if accepted.session_id == session_id && accepted.turn_id == turn_id { Ok(accepted.turn_id) } else { - Err(anyhow::anyhow!( - "Runtime accepted a Shell command with an unexpected identity" - )) + Err(anyhow::Error::new(SessionOperationError::unexpected( + anyhow::anyhow!( + "Runtime accepted a Shell command with an unexpected identity" + ), + ))) } } - CliAgentRuntimeBackend::Shared(client) => match client + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::RunUserShellCommand { request }) .await { @@ -1610,13 +1931,19 @@ impl ExecAgentRuntimeClient { }) if accepted_session == session_id && accepted_turn == turn_id => { Ok(accepted_turn) } - Ok(_) => Err(unexpected_shared_result("run_user_shell_command")), - Err(error) => Err(anyhow::Error::new(error)), + Ok(_) => Err(anyhow::Error::new(SessionOperationError::unexpected( + unexpected_shared_result("run_user_shell_command"), + ))), + Err(error) => Err(anyhow::Error::new(SessionOperationError::shared(error))), }, } } .await; - if submission.is_err() { + if submission + .as_ref() + .err() + .is_some_and(should_clear_turn_after_submission_error) + { *self.current_turn_id.lock().await = None; } submission @@ -1638,11 +1965,11 @@ impl ExecAgentRuntimeClient { limit: 20, }; match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .search_workspace_references(request) .await .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => match client + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::SearchWorkspaceReferences { request }) .await? { @@ -1662,11 +1989,11 @@ impl ExecAgentRuntimeClient { message_id, }; match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .workspace_references_for_message(request) .await .map_err(|error| anyhow::anyhow!(error.into_message())), - CliAgentRuntimeBackend::Shared(client) => match client + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::WorkspaceReferencesForMessage { request }) .await? { @@ -1692,13 +2019,13 @@ impl ExecAgentRuntimeClient { cancel_descendants: true, }; match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => { + TuiRuntimePort::Embedded(runtime) => { runtime .cancel_turn(request) .await .map_err(|error| anyhow::anyhow!(error.into_message()))?; } - CliAgentRuntimeBackend::Shared(client) => match client + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::CancelTurn { request }) .await? { @@ -1732,16 +2059,22 @@ impl ExecAgentRuntimeClient { metadata: serde_json::Map::new(), }; let session = match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime + TuiRuntimePort::Embedded(runtime) => runtime .create_session(request) .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?, - CliAgentRuntimeBackend::Shared(client) => match client + .map_err(runtime_side_effect_error)?, + TuiRuntimePort::Shared(client) => match client .request(RuntimeIpcOperation::CreateSession { request }) - .await? + .await + .map_err(SessionOperationError::shared) + .map_err(anyhow::Error::new)? { RuntimeIpcOperationResult::SessionCreated { session } => session, - _ => return Err(unexpected_shared_result("create_session")), + _ => { + return Err(anyhow::Error::new(SessionOperationError::unexpected( + unexpected_shared_result("create_session"), + ))) + } }, }; @@ -1758,6 +2091,17 @@ impl ExecAgentRuntimeClient { Ok(id) } + pub(crate) async fn create_new_session_with_workspace_binding( + &self, + agent_type: &str, + ) -> Result<(String, AgentSessionWorkspaceBinding)> { + let session_id = self.create_new_session(agent_type).await?; + let binding = self + .workspace_binding_after_session_creation(&session_id) + .await?; + Ok((session_id, binding)) + } + pub(crate) async fn restore_session(&self, session_id: &str) -> Result<()> { self.restore_session_in_current_workspace(session_id) .await?; @@ -1766,33 +2110,42 @@ impl ExecAgentRuntimeClient { pub(crate) async fn submit_user_answers( &self, - tool_id: &str, + session_id: String, + turn_id: String, + tool_id: String, + registration_sequence: u64, answers: serde_json::Value, ) -> Result<()> { tracing::info!("Submitting user answers for tool: {}", tool_id); + let request = AgentUserAnswersRequest { + session_id, + turn_id, + tool_id, + registration_sequence, + answers, + }; match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .submit_user_answers(AgentUserAnswersRequest { - tool_id: tool_id.to_string(), - answers, - }) + TuiRuntimePort::Embedded(runtime) => runtime + .submit_user_answers(request) .await - .map_err(|e| anyhow::anyhow!("Submit user answers failed: {}", e.into_message())), - CliAgentRuntimeBackend::Shared(client) => { - let session_id = self.require_session_id().await?; - expect_unit( - client - .request(RuntimeIpcOperation::SubmitUserAnswers { - request: RuntimeUserAnswersRequest { - session_id, - tool_id: tool_id.to_string(), - answers, - }, - }) - .await?, - "submit_user_answers", - ) - } + .map_err(SessionOperationError::runtime) + .map_err(anyhow::Error::new), + TuiRuntimePort::Shared(client) => expect_side_effect_unit( + client + .request(RuntimeIpcOperation::SubmitUserAnswers { + request: RuntimeUserAnswersRequest { + session_id: request.session_id, + turn_id: request.turn_id, + tool_id: request.tool_id, + registration_sequence: request.registration_sequence, + answers: request.answers, + }, + }) + .await + .map_err(SessionOperationError::shared) + .map_err(anyhow::Error::new)?, + "submit_user_answers", + ), } } @@ -1805,7 +2158,7 @@ impl ExecAgentRuntimeClient { } async fn refresh_shared_pending_permissions(&self) -> Result<()> { - let CliAgentRuntimeBackend::Shared(client) = &self.backend else { + let TuiRuntimePort::Shared(client) = &self.backend else { return Ok(()); }; let session_id = self.require_session_id().await?; @@ -1851,7 +2204,7 @@ fn spawn_shared_event_bridge( agent_owner: SharedBroadcast, permission_owner: SharedBroadcast, pending: Arc>>, -) { +) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { loop { match source.recv().await { @@ -1922,7 +2275,15 @@ fn spawn_shared_event_bridge( *permission_owner .write() .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - }); + }) +} + +impl Drop for CliAgentRuntimeClient { + fn drop(&mut self) { + if let Some(bridge) = self.shared_event_bridge.take() { + bridge.abort(); + } + } } fn shared_disconnect_message(reason: Option) -> String { @@ -1957,6 +2318,15 @@ pub(super) fn expect_unit(result: RuntimeIpcOperationResult, operation: &str) -> } } +fn expect_side_effect_unit(result: RuntimeIpcOperationResult, operation: &str) -> Result<()> { + match result { + RuntimeIpcOperationResult::Unit => Ok(()), + _ => Err(anyhow::Error::new(SessionOperationError::unexpected( + unexpected_shared_result(operation), + ))), + } +} + fn unexpected_shared_result(operation: &str) -> anyhow::Error { anyhow::anyhow!("Shared Runtime returned an unexpected result for {operation}") } @@ -1985,26 +2355,147 @@ mod recovery_tests { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::path::Path; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, RwLock, + }; use bitfun_runtime_ports::{ - AgentSessionSummary, AgentSessionWorkspaceBinding, SessionExecutionTarget, - SessionExecutionTargetKind, WorktreeLifecycle, + AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionDeleteRequest, + AgentSessionListRequest, AgentSessionManagementPort, AgentSessionRenameRequest, + AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, + AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, PortResult, + SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, }; use bitfun_agent_runtime::sdk::{ - PermissionDelegationContext, PermissionRequest, PermissionRequestEvent, - PermissionRequestSource, PermissionRequestSourceKind, PortError, PortErrorKind, - RuntimeError, + AgentRuntime, AgentRuntimeBuilder, AgentSessionUsageRequest, PermissionDelegationContext, + PermissionRequest, PermissionRequestEvent, PermissionRequestSource, + PermissionRequestSourceKind, PortError, PortErrorKind, RuntimeError, }; use bitfun_agent_runtime_ipc::{RuntimeIpcClientError, RuntimeIpcError, RuntimeIpcErrorCode}; use super::{ - project_routed_permission_event, session_migration_notices, shared_disconnect_message, - shared_restore_error, validated_session_summary, CliWorkspacePaths, SessionMigrationNotice, - SessionOperationError, + merge_session_usage_remote_scope, project_routed_permission_event, + runtime_side_effect_error, session_migration_notices, shared_disconnect_message, + shared_restore_error, should_clear_turn_after_submission_error, + side_effect_error_outcome_is_unknown, validated_accepted_turn, validated_session_summary, + CliAgentRuntimeClient, CliWorkspacePaths, SessionMigrationNotice, SessionOperationError, + TuiRuntimePort, }; use bitfun_agent_runtime_ipc::RuntimeIpcStreamInvalidationReason; + use tokio::sync::Mutex; + + struct CreateThenBindingFailsPort { + create_calls: AtomicUsize, + } + + #[async_trait::async_trait] + impl AgentSubmissionPort for CreateThenBindingFailsPort { + async fn create_session( + &self, + request: AgentSessionCreateRequest, + ) -> PortResult { + self.create_calls.fetch_add(1, Ordering::SeqCst); + let mut session = AgentSessionCreateResult::new( + "created-session", + request.session_name, + request.agent_type, + ); + session.workspace_path = request.workspace_path; + session.project_workspace_path = request.project_workspace_path; + session.execution_target = request.execution_target; + Ok(session) + } + + async fn submit_message( + &self, + request: AgentSubmissionRequest, + ) -> PortResult { + Ok(AgentSubmissionResult { + turn_id: request.turn_id.unwrap_or_else(|| "turn-1".to_string()), + accepted: true, + }) + } + + async fn resolve_session_agent_type( + &self, + _session_id: &str, + ) -> PortResult> { + Ok(Some("agentic".to_string())) + } + } + + #[async_trait::async_trait] + impl AgentSessionManagementPort for CreateThenBindingFailsPort { + async fn list_sessions( + &self, + _request: AgentSessionListRequest, + ) -> PortResult> { + Ok(Vec::new()) + } + + async fn delete_session(&self, _request: AgentSessionDeleteRequest) -> PortResult<()> { + Ok(()) + } + + async fn rename_session(&self, _request: AgentSessionRenameRequest) -> PortResult<()> { + Ok(()) + } + + async fn resolve_session_workspace_binding( + &self, + _request: AgentSessionWorkspaceRequest, + ) -> PortResult> { + Err(PortError::new( + PortErrorKind::NotAvailable, + "workspace binding store unavailable", + )) + } + } + + fn embedded_client_for_test(runtime: AgentRuntime, workspace: &Path) -> CliAgentRuntimeClient { + CliAgentRuntimeClient { + backend: TuiRuntimePort::Embedded(runtime), + approval_policy: Arc::new(RwLock::new(super::CliApprovalPolicy::Ask)), + workspace_paths: Arc::new(RwLock::new(CliWorkspacePaths::new(Some( + workspace.to_path_buf(), + )))), + session_id: Arc::new(Mutex::new(None)), + current_turn_id: Arc::new(Mutex::new(None)), + shared_agent_events: None, + shared_permission_events: None, + shared_pending_permissions: Arc::new(RwLock::new(HashMap::new())), + shared_event_bridge: None, + } + } + + #[tokio::test] + async fn direct_create_binding_failure_is_outcome_unknown_without_recreating() { + let workspace = tempfile::tempdir().expect("workspace"); + let port = Arc::new(CreateThenBindingFailsPort { + create_calls: AtomicUsize::new(0), + }); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(port.clone()) + .with_session_management_port(port.clone()) + .build() + .expect("runtime"); + let client = embedded_client_for_test(runtime, workspace.path()); + + let result = client + .create_new_session_with_workspace_binding("agentic") + .await; + + let error = result.expect_err("binding failure after create must fail closed"); + assert!(error + .downcast_ref::() + .is_some_and(SessionOperationError::outcome_unknown)); + assert!(error.to_string().contains("Session creation completed")); + assert_eq!(port.create_calls.load(Ordering::SeqCst), 1); + } #[test] fn oversized_shared_restore_explains_the_embedded_handoff() { @@ -2034,6 +2525,9 @@ mod tests { ] { assert!(SessionOperationError::shared(transport_error).outcome_unknown()); } + assert!(side_effect_error_outcome_is_unknown(&anyhow::Error::new( + RuntimeIpcClientError::Timeout, + ))); assert!( SessionOperationError::unexpected(anyhow::anyhow!("unexpected response shape")) .outcome_unknown() @@ -2145,6 +2639,112 @@ mod tests { .is_none()); } + #[test] + fn workspace_binding_retains_remote_request_scope_without_local_fallback() { + let mut paths = CliWorkspacePaths::new(Some(Path::new("D:/project").to_path_buf())); + paths.apply_binding(&AgentSessionWorkspaceBinding { + workspace_id: Some("workspace-remote".to_string()), + workspace_path: "/srv/worktree".to_string(), + project_workspace_path: Some("D:/project".to_string()), + execution_target: Some(SessionExecutionTarget::local("/srv/worktree")), + remote_connection_id: Some("connection-remote".to_string()), + remote_ssh_host: Some("build-host".to_string()), + }); + + assert_eq!( + paths.remote_scope(), + ( + Some("connection-remote".to_string()), + Some("build-host".to_string()) + ) + ); + + paths.reset_execution_to_project(); + assert_eq!(paths.remote_scope(), (None, None)); + } + + #[test] + fn session_usage_inherits_authoritative_remote_scope_and_rejects_conflicts() { + let request = AgentSessionUsageRequest { + session_id: "session-remote".to_string(), + workspace_path: Some("/srv/project".to_string()), + remote_connection_id: None, + remote_ssh_host: None, + include_hidden_subagents: true, + }; + + let merged = merge_session_usage_remote_scope( + request.clone(), + ( + Some("connection-remote".to_string()), + Some("build-host".to_string()), + ), + ) + .expect("authoritative remote scope should fill omitted request facts"); + assert_eq!( + merged.remote_connection_id.as_deref(), + Some("connection-remote") + ); + assert_eq!(merged.remote_ssh_host.as_deref(), Some("build-host")); + + let mut conflicting = request; + conflicting.remote_connection_id = Some("another-connection".to_string()); + let error = merge_session_usage_remote_scope( + conflicting, + (Some("connection-remote".to_string()), None), + ) + .expect_err("conflicting remote identity must fail closed"); + assert!(error.to_string().contains("remote connection")); + } + + #[test] + fn side_effecting_turns_keep_identity_when_the_outcome_is_unknown() { + let unknown = anyhow::Error::new(SessionOperationError::shared( + RuntimeIpcClientError::Timeout, + )); + let rejected = anyhow::Error::new(SessionOperationError::shared( + RuntimeIpcClientError::Remote(RuntimeIpcError { + code: RuntimeIpcErrorCode::NotFound, + message: "session missing".to_string(), + }), + )); + + assert!(!should_clear_turn_after_submission_error(&unknown)); + assert!(should_clear_turn_after_submission_error(&rejected)); + } + + #[test] + fn embedded_retry_preserves_a_typed_unknown_outcome() { + let error = runtime_side_effect_error(RuntimeError::Port(PortError::new( + PortErrorKind::OutcomeUnknown, + "the retry may have been accepted", + ))); + + assert!(error + .downcast_ref::() + .is_some_and(SessionOperationError::outcome_unknown)); + assert!(!should_clear_turn_after_submission_error(&error)); + } + + #[test] + fn accepted_turn_identity_mismatch_is_outcome_unknown() { + for (accepted_session, accepted_turn) in + [("other-session", "turn"), ("session", "other-turn")] + { + let error = validated_accepted_turn( + "submit_turn", + "session", + "turn", + accepted_session.to_string(), + accepted_turn.to_string(), + ) + .expect_err("mismatched accepted identity must fail closed"); + assert!(error + .downcast_ref::() + .is_some_and(SessionOperationError::outcome_unknown)); + } + } + #[test] fn workspace_diff_fails_closed_for_other_worktrees_and_remote_sessions() { let mut paths = CliWorkspacePaths::new(Some("/project".into())); @@ -2189,9 +2789,9 @@ mod tests { let compatibility_update = ["self.compatibility", "\n .update_session_model"].concat(); - assert!(source.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(source.contains("TuiRuntimePort::Embedded(runtime)")); assert!(source.contains("runtime.update_session_model(request)")); - assert!(source.contains("CliAgentRuntimeBackend::Shared(client)")); + assert!(source.contains("TuiRuntimePort::Shared(client)")); assert!(source.contains("RuntimeIpcOperation::UpdateSessionModel { request }")); assert!(!source.contains(&compatibility_update)); } @@ -2225,7 +2825,7 @@ mod tests { .0; assert!(source.contains("pub(crate) async fn rename_session(")); - assert!(rename.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(rename.contains("TuiRuntimePort::Embedded(runtime)")); assert!(rename.contains(".rename_session(request)")); assert!(rename.contains("RuntimeIpcOperation::RenameSession")); assert!(!rename.contains("serde_json::to_value")); @@ -2243,7 +2843,7 @@ mod tests { .expect("compaction method boundary") .0; - assert!(compact.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(compact.contains("TuiRuntimePort::Embedded(runtime)")); assert!(compact.contains(".start_session_compaction(request)")); assert!(compact.contains("RuntimeIpcOperation::CompactSession { request }")); assert!(compact.contains("RuntimeIpcOperationResult::TurnAccepted")); @@ -2263,9 +2863,9 @@ mod tests { .0; assert!(steering.contains("AgentDialogSteerRequest")); - assert!(steering.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(steering.contains("TuiRuntimePort::Embedded(runtime)")); assert!(steering.contains(".steer_dialog_turn(request)")); - assert!(steering.contains("CliAgentRuntimeBackend::Shared(client)")); + assert!(steering.contains("TuiRuntimePort::Shared(client)")); assert!(steering.contains("RuntimeIpcOperation::SteerTurn { request }")); assert!(steering.contains("RuntimeIpcOperationResult::TurnSteered")); assert!(!steering.contains("RuntimeIpcOperation::SubmitTurn")); @@ -2327,10 +2927,10 @@ mod tests { .expect("interactive fork method boundary") .0; - assert!(fork.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(fork.contains("TuiRuntimePort::Embedded(runtime)")); assert!(fork.contains(".fork_session_before_turn(")); assert!(fork.contains(".fork_session(AgentSessionForkRequest")); - assert!(fork.contains("CliAgentRuntimeBackend::Shared(client)")); + assert!(fork.contains("TuiRuntimePort::Shared(client)")); assert!(fork.contains("RuntimeIpcOperation::ForkSession")); assert!(fork.contains("RuntimeIpcOperationResult::SessionForked")); } @@ -2347,13 +2947,52 @@ mod tests { .0; assert!(workspace_diff.contains("workspace_diff_unavailable_reason")); - assert!(workspace_diff.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(workspace_diff.contains("TuiRuntimePort::Embedded(runtime)")); assert!(workspace_diff.contains(".workspace_diff()")); - assert!(workspace_diff.contains("CliAgentRuntimeBackend::Shared(client)")); + assert!(workspace_diff.contains("TuiRuntimePort::Shared(client)")); assert!(workspace_diff.contains("RuntimeIpcOperation::WorkspaceDiff")); assert!(workspace_diff.contains("RuntimeIpcOperationResult::WorkspaceDiff")); } + #[test] + fn session_usage_uses_the_same_runtime_boundary_in_both_deployments() { + let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); + let usage = source + .split_once("pub(crate) async fn generate_session_usage_report(") + .expect("session usage method") + .1 + .split_once("pub(crate) async fn wait_for_turn_settlement(") + .expect("session usage method boundary") + .0; + + assert!(usage.contains("TuiRuntimePort::Embedded(runtime)")); + assert!(usage.contains(".generate_session_usage(request)")); + assert!(usage.contains("TuiRuntimePort::Shared(client)")); + assert!(usage.contains("RuntimeIpcOperation::SessionUsage { request }")); + assert!(usage.contains("RuntimeIpcOperationResult::SessionUsage { usage }")); + assert!(usage.contains("merge_session_usage_remote_scope")); + assert!(!usage.contains("embedded_runtime")); + } + + #[test] + fn turn_settlement_uses_the_same_runtime_boundary_in_both_deployments() { + let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); + let settlement = source + .split_once("pub(crate) async fn wait_for_turn_settlement(") + .expect("settlement method") + .1 + .split_once("fn build_default_session_name()") + .expect("settlement method boundary") + .0; + + assert!(settlement.contains("TuiRuntimePort::Embedded(runtime)")); + assert!(settlement.contains(".wait_for_turn_settlement(request)")); + assert!(settlement.contains("TuiRuntimePort::Shared(client)")); + assert!(settlement.contains("RuntimeIpcOperation::WaitForSettlement { request }")); + assert!(settlement.contains("RuntimeIpcOperationResult::Unit")); + assert!(!settlement.contains("NotAvailable")); + } + #[test] fn session_revert_uses_the_same_authoritative_result_in_both_deployments() { let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); @@ -2365,7 +3004,7 @@ mod tests { .expect("session revert method boundary") .0; - assert!(revert.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(revert.contains("TuiRuntimePort::Embedded(runtime)")); assert!(revert.contains("runtime.undo_session(request)")); assert!(revert.contains("runtime.redo_session(request)")); assert!(revert.contains("RuntimeIpcOperation::UndoSession")); @@ -2384,9 +3023,9 @@ mod tests { .expect("delete method boundary") .0; - assert!(delete.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(delete.contains("TuiRuntimePort::Embedded(runtime)")); assert!(delete.contains(".delete_session(AgentSessionDeleteRequest {")); - assert!(delete.contains("CliAgentRuntimeBackend::Shared(client)")); + assert!(delete.contains("TuiRuntimePort::Shared(client)")); assert!(delete.contains("RuntimeIpcOperation::DeleteSession")); assert!(!delete.contains("embedded_runtime")); assert!(!delete.contains("serde_json::to_value")); @@ -2402,9 +3041,9 @@ mod tests { ] .concat(); - assert!(source.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(source.contains("TuiRuntimePort::Embedded(runtime)")); assert!(source.contains("runtime.update_session_mode(request)")); - assert!(source.contains("CliAgentRuntimeBackend::Shared(client)")); + assert!(source.contains("TuiRuntimePort::Shared(client)")); assert!(source.contains("RuntimeIpcOperation::UpdateSessionMode { request }")); assert!(!source.contains(&compatibility_update)); } diff --git a/src/apps/cli/src/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs index 527508dfd5..a211a9dfdd 100644 --- a/src/apps/cli/src/agent/tui_client.rs +++ b/src/apps/cli/src/agent/tui_client.rs @@ -1,325 +1,130 @@ -//! Interactive TUI session state projected over the App Server backend boundary. +//! Thin interactive TUI facade over the shared CLI Runtime client and domain owners. -use std::collections::HashMap; -use std::fmt; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, RwLock}; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; -use crate::tui_backend::{TuiBackend, TuiBackendError, TuiBackendErrorKind}; use anyhow::Result; -use bitfun_app_server_client::AppServerEvent; -use bitfun_app_server_protocol::account::*; -use bitfun_app_server_protocol::agent::*; -use bitfun_app_server_protocol::event::EventStreamState; -use bitfun_app_server_protocol::external_source::*; -use bitfun_app_server_protocol::hook::*; -use bitfun_app_server_protocol::mcp::*; -use bitfun_app_server_protocol::model::*; -use bitfun_app_server_protocol::session::*; -use bitfun_app_server_protocol::skill::*; -use bitfun_app_server_protocol::subagent::*; -use bitfun_app_server_protocol::workspace::*; -use bitfun_app_server_protocol::worktree::*; use bitfun_core_types::SessionUsageReport; -use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; +use bitfun_events::AgenticEventEnvelope; +use bitfun_product_domains::external_hook_import::{ + ExternalHookImportApplyRequestV1, ExternalHookImportApplyResultV1, + ExternalHookImportMutationRequestV1, ExternalHookImportPlanV1, ExternalHookImportSnapshotV1, +}; use bitfun_product_domains::external_source_control::ExternalSourceControlRequestV1; use bitfun_product_domains::external_sources::{ ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourcePublicSnapshot, - NativePromptCommandDescriptor, PromptCommandShellReviewDecision, + NativePromptCommandDescriptor, PromptCommandInvocationOutcome, + PromptCommandShellReviewDecision, SourceKey, }; use bitfun_product_domains::tool_permissions::{ PermissionReply, PermissionRequest, PermissionRequestEvent, }; use bitfun_runtime_ports::{ - put_agent_workspace_references, AgentContextReloadRequest, AgentDialogSteerRequest, - AgentDialogTurnExecution, AgentDialogTurnRequest, AgentInputAttachment, - AgentMessageWorkspaceReferencesRequest, AgentSessionCompactionRequest, - AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionLineageCancellationRequest, - AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, - AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionModeUpdateRequest, - AgentSessionModelUpdateRequest, AgentSessionRenameRequest, AgentSessionRevertRequest, - AgentSessionRevertResult, AgentSessionSummary, AgentSessionUsageRequest, - AgentSessionWorkspaceBinding, AgentSubmissionSource, AgentTurnCancellationRequest, - AgentTurnCancellationResult, AgentTurnSettlementRequest, AgentUserShellCommandRequest, - AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, - AgentWorkspaceReferenceSearchResult, DialogSubmissionPolicy, SessionExecutionTarget, - SessionTranscript, WorkspaceDiffSnapshot, + AgentContextReloadRequest, AgentInputAttachment, AgentSessionLineageInspection, + AgentSessionLineageSnapshot, AgentSessionRevertResult, AgentSessionSummary, + AgentSessionUsageRequest, AgentSessionWorkspaceBinding, AgentTurnCancellationResult, + AgentWorkspaceReference, AgentWorkspaceReferenceSearchResult, SessionTranscript, + WorkspaceDiffSnapshot, }; -use tokio::sync::{broadcast, Mutex}; - -use crate::runtime::approval::{approval_metadata, CliApprovalPolicy}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct TuiAgentMode { - pub(crate) id: String, - pub(crate) description: String, - pub(crate) model_id: Option, - pub(crate) is_external: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum SessionMigrationNotice { - Mode { - previous_id: String, - restored_id: String, - }, - Model { - previous_id: String, - restored_id: String, - }, -} - -impl SessionMigrationNotice { - pub(crate) fn user_message(&self) -> String { - let (setting, previous_id, restored_id) = match self { - Self::Mode { - previous_id, - restored_id, - } => ("mode", previous_id, restored_id), - Self::Model { - previous_id, - restored_id, - } => ("model", previous_id, restored_id), - }; - format!( - "Session {setting} \"{previous_id}\" is unavailable. This session was restored with \"{restored_id}\". Review the {setting} before continuing." - ) - } -} - -#[derive(Debug)] -pub(crate) struct SessionOperationError { - message: String, - outcome_unknown: bool, -} - -impl fmt::Display for SessionOperationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.message) - } -} - -impl std::error::Error for SessionOperationError {} - -impl SessionOperationError { - fn backend(error: TuiBackendError) -> Self { - Self { - message: error.message, - outcome_unknown: error.outcome_unknown, - } - } - - pub(crate) fn outcome_unknown(&self) -> bool { - self.outcome_unknown - } -} - -#[derive(Clone, Debug)] -struct TuiWorkspacePaths { - workspace_id: Option, - project: Option, - execution: Option, - execution_target: Option, - remote_connection_id: Option, - remote_ssh_host: Option, -} - -impl TuiWorkspacePaths { - fn new(workspace_path: Option) -> Self { - Self { - workspace_id: None, - project: workspace_path.clone(), - execution: workspace_path, - execution_target: None, - remote_connection_id: None, - remote_ssh_host: None, - } - } +use tokio::sync::broadcast; +use tokio::task::JoinHandle; - fn execution(&self) -> PathBuf { - self.execution - .clone() - .or_else(|| self.project.clone()) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from(".")) - } +use super::runtime_client::{CliAgentMode, CliAgentRuntimeClient}; +pub(crate) use super::runtime_client::{ + CliSessionRestoreSnapshot, SessionMigrationNotice, SessionOperationError, +}; +use crate::runtime::approval::CliApprovalPolicy; +use crate::tui_management::*; - fn project(&self) -> PathBuf { - self.project - .clone() - .or_else(|| self.execution.clone()) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from(".")) - } - - fn apply_binding(&mut self, binding: &AgentSessionWorkspaceBinding) { - self.workspace_id = binding.workspace_id.clone(); - let execution = PathBuf::from(&binding.workspace_path); - let project = binding - .project_workspace_path - .as_deref() - .filter(|path| !path.trim().is_empty()) - .map(PathBuf::from) - .unwrap_or_else(|| self.project()); - self.execution = Some(execution); - self.project = Some(project); - self.execution_target = binding.execution_target.clone(); - self.remote_connection_id = binding.remote_connection_id.clone(); - self.remote_ssh_host = binding.remote_ssh_host.clone(); - } - - fn reset_execution_to_project(&mut self) -> PathBuf { - let project = self.project(); - self.execution = Some(project.clone()); - self.execution_target = Some(SessionExecutionTarget::local( - project.to_string_lossy().to_string(), - )); - self.workspace_id = None; - self.remote_connection_id = None; - self.remote_ssh_host = None; - project - } - - fn workspace_diff_unavailable_reason(&self) -> Option<&'static str> { - if self.remote_connection_id.is_some() || self.remote_ssh_host.is_some() { - return Some("Workspace diff is unavailable for remote Sessions"); - } - if !same_workspace_location(&self.execution(), &self.project()) { - return Some( - "Workspace diff is unavailable when the Session uses a different worktree", - ); - } - None - } -} +pub(crate) type TuiAgentMode = CliAgentMode; +/// Presentation facade only. Runtime state lives in `CliAgentRuntimeClient`; +/// management behavior remains in the concrete owners composed below. pub(crate) struct TuiAgentClient { - backend: Arc, - shared: bool, - approval_policy: Arc>, - workspace_paths: Arc>, - session_id: Arc>>, - current_turn_id: Arc>>, - agent_events: Arc>>>, - permission_events: Arc>>>, - external_source_events: - Arc>>>, - pending_permissions: Arc>>, + runtime: Arc, + management: Arc, + external_source_events: broadcast::Sender<(String, ExternalSourcePublicSnapshot)>, + external_source_subscriptions: Arc>>, + external_source_tasks: Mutex>>, } impl TuiAgentClient { pub(crate) fn new( - backend: Arc, - workspace_path: Option, - shared: bool, - approval_policy: CliApprovalPolicy, + runtime: Arc, + management: Arc, ) -> Self { - let (agent_sender, _) = broadcast::channel(256); - let (permission_sender, _) = broadcast::channel(64); - let (external_source_sender, _) = broadcast::channel(64); - let agent_events = Arc::new(RwLock::new(Some(agent_sender.clone()))); - let permission_events = Arc::new(RwLock::new(Some(permission_sender.clone()))); - let external_source_events = Arc::new(RwLock::new(Some(external_source_sender.clone()))); - let pending_permissions = Arc::new(RwLock::new(HashMap::new())); - spawn_event_bridge( - backend.subscribe_events(), - agent_sender, - permission_sender, - external_source_sender, - agent_events.clone(), - permission_events.clone(), - external_source_events.clone(), - pending_permissions.clone(), - ); + let (external_source_events, _) = broadcast::channel(64); Self { - backend, - shared, - approval_policy: Arc::new(RwLock::new(approval_policy)), - workspace_paths: Arc::new(RwLock::new(TuiWorkspacePaths::new(workspace_path))), - session_id: Arc::new(Mutex::new(None)), - current_turn_id: Arc::new(Mutex::new(None)), - agent_events, - permission_events, + runtime, + management, external_source_events, - pending_permissions, + external_source_subscriptions: Arc::new(Mutex::new(HashSet::new())), + external_source_tasks: Mutex::new(Vec::new()), } } pub(crate) fn is_shared(&self) -> bool { - self.shared + self.runtime.is_shared() } - pub(crate) async fn model_catalog(&self) -> Result { - Ok(self.backend.model_catalog().await?) + fn management_scope(&self) -> ManagementScope { + management_scope( + self.project_workspace_path_buf(), + self.runtime.is_remote_workspace(), + ) } - pub(crate) async fn available_agent_modes(&self) -> Result> { - let response = self - .backend - .list_agent_modes(ListAgentModesRequest { - workspace_path: Some(self.workspace_path_buf().to_string_lossy().to_string()), - include_external: true, - }) - .await - .map_err(|error| anyhow::anyhow!(error))?; - Ok(response - .modes - .into_iter() - .map(|mode| TuiAgentMode { - id: mode.id, - description: mode.description, - model_id: mode.model_id, - is_external: mode.is_external, - }) - .collect()) + fn local_management_scope(&self, capability: &str) -> ManagementResult { + let scope = self.management_scope(); + scope.local_workspace(capability)?; + Ok(scope) } - pub(crate) async fn list_models(&self) -> Result { - self.backend - .list_models() - .await - .map_err(|error| anyhow::anyhow!(error)) + pub(crate) async fn model_catalog(&self) -> Result { + let scope = self.management_scope(); + Ok(self.management.model.catalog(&scope).await?) } - pub(crate) async fn get_model(&self, model_id: String) -> Result { - self.backend - .get_model(GetModelRequest { model_id }) - .await - .map_err(|error| anyhow::anyhow!(error)) + pub(crate) async fn available_agent_modes(&self) -> Result> { + self.runtime.available_agent_modes().await } - pub(crate) async fn add_model(&self, request: AddModelRequest) -> Result { - self.backend - .add_model(request) - .await - .map_err(|error| anyhow::anyhow!(error)) + pub(crate) async fn list_models(&self) -> Result { + let scope = self.management_scope(); + Ok(self.management.model.list(&scope).await?) } - pub(crate) async fn update_model( - &self, - request: UpdateModelRequest, - ) -> Result { - self.backend - .update_model(request) - .await - .map_err(|error| anyhow::anyhow!(error)) + pub(crate) async fn get_model(&self, model_id: String) -> Result { + let scope = self.management_scope(); + Ok(self.management.model.get(&scope, &model_id).await?) } - pub(crate) async fn delete_model(&self, model_id: String) -> Result { - self.backend - .delete_model(DeleteModelRequest { model_id }) - .await - .map_err(|error| anyhow::anyhow!(error)) + pub(crate) async fn add_model(&self, request: AddModelRequest) -> Result<()> { + let scope = self.management_scope(); + Ok(self + .management + .model + .add(&scope, request.model, request.make_primary_if_empty) + .await?) } - pub(crate) async fn set_model_default( - &self, - request: SetModelDefaultRequest, - ) -> Result { - self.backend - .set_model_default(request) - .await - .map_err(|error| anyhow::anyhow!(error)) + pub(crate) async fn update_model(&self, request: UpdateModelRequest) -> Result<()> { + let scope = self.management_scope(); + Ok(self + .management + .model + .update(&scope, &request.model_id, request.model) + .await?) + } + + pub(crate) async fn set_model_default(&self, request: SetModelDefaultRequest) -> Result<()> { + let scope = self.management_scope(); + Ok(self + .management + .model + .set_default(&scope, request.slot, request.model_id) + .await?) } pub(crate) async fn list_skills( @@ -327,14 +132,14 @@ impl TuiAgentClient { mode_id: String, manageable: bool, ) -> Result { - self.backend - .list_skills(ListSkillsRequest { - workspace_path: self.workspace_path_buf().to_string_lossy().to_string(), - mode_id, - manageable, - }) - .await - .map_err(|error| anyhow::anyhow!(error)) + let scope = self.management_scope(); + Ok(ListSkillsResponse { + skills: self + .management + .registry + .list_skills(&scope, &mode_id, manageable) + .await?, + }) } pub(crate) async fn set_skill_enabled( @@ -344,18 +149,20 @@ impl TuiAgentClient { enabled: bool, default_enabled: bool, level: String, - ) -> Result { - self.backend - .set_skill_enabled(SetSkillEnabledRequest { - workspace_path: self.workspace_path_buf().to_string_lossy().to_string(), - mode_id, - skill_key, + ) -> Result<()> { + let scope = self.management_scope(); + Ok(self + .management + .registry + .set_skill_enabled( + &scope, + &mode_id, + &skill_key, enabled, default_enabled, - level, - }) - .await - .map_err(|error| anyhow::anyhow!(error)) + &level, + ) + .await?) } pub(crate) async fn list_subagents( @@ -363,14 +170,16 @@ impl TuiAgentClient { parent_mode_id: String, management: bool, ) -> Result { - self.backend - .list_subagents(ListSubagentsRequest { - workspace_path: self.workspace_path_buf().to_string_lossy().to_string(), - parent_mode_id, - management, - }) - .await - .map_err(|error| anyhow::anyhow!(error)) + let scope = self.management_scope(); + let (subagents, has_external) = self + .management + .registry + .list_subagents(&scope, &parent_mode_id, management, true) + .await?; + Ok(ListSubagentsResponse { + subagents, + has_external, + }) } pub(crate) async fn set_subagent_enabled( @@ -378,128 +187,103 @@ impl TuiAgentClient { parent_mode_id: String, subagent_id: String, enabled: bool, - ) -> Result { - self.backend - .set_subagent_enabled(SetSubagentEnabledRequest { - workspace_path: self.workspace_path_buf().to_string_lossy().to_string(), - parent_mode_id, - subagent_id, - enabled, - }) - .await - .map_err(|error| anyhow::anyhow!(error)) + ) -> Result<()> { + let scope = self.management_scope(); + Ok(self + .management + .registry + .set_subagent_enabled(&scope, &parent_mode_id, &subagent_id, enabled) + .await?) } - pub(crate) async fn list_mcp_servers(&self) -> Result { - self.backend - .list_mcp_servers(ListMcpServersRequest { - workspace_path: self.workspace_path_buf().to_string_lossy().to_string(), - }) - .await - .map_err(|error| anyhow::anyhow!(error)) + pub(crate) async fn list_mcp_servers(&self) -> Result { + let scope = self.management_scope(); + Ok(self.management.mcp.list(&scope).await?) } - pub(crate) async fn toggle_mcp_server( - &self, - server_id: String, - ) -> Result { - self.backend - .toggle_mcp_server(ToggleMcpServerRequest { server_id }) - .await - .map_err(|error| anyhow::anyhow!(error)) + pub(crate) async fn toggle_mcp_server(&self, server_id: String) -> Result<()> { + let scope = self.management_scope(); + Ok(self.management.mcp.toggle(&scope, &server_id).await?) } pub(crate) async fn add_mcp_server( &self, name: String, config: McpServerMutation, - ) -> Result { - self.backend - .add_mcp_server(AddMcpServerRequest { name, config }) - .await - .map_err(|error| anyhow::anyhow!(error)) + ) -> Result<()> { + let scope = self.management_scope(); + Ok(self.management.mcp.add(&scope, &name, config).await?) } - pub(crate) async fn delete_mcp_server( - &self, - server_id: String, - ) -> Result { - self.backend - .delete_mcp_server(DeleteMcpServerRequest { server_id }) - .await - .map_err(|error| anyhow::anyhow!(error)) + pub(crate) async fn delete_mcp_server(&self, server_id: String) -> Result<()> { + let scope = self.management_scope(); + Ok(self.management.mcp.delete(&scope, &server_id).await?) } pub(crate) async fn external_mcp_decision( &self, request: ExternalMcpDecisionRequest, - ) -> Result { - self.backend - .external_mcp_decision(request) - .await - .map_err(|error| anyhow::anyhow!(error)) + ) -> Result<()> { + let scope = self.management_scope(); + Ok(self.management.mcp.decide_external(&scope, request).await?) } pub(crate) async fn mcp_conflict_choice( &self, request: McpConflictChoiceRequest, - ) -> Result { - self.backend - .mcp_conflict_choice(request) - .await - .map_err(|error| anyhow::anyhow!(error)) + ) -> Result<()> { + let scope = self.management_scope(); + Ok(self.management.mcp.choose_conflict(&scope, request).await?) } pub(crate) async fn external_source_snapshot( &self, force_refresh: bool, - ) -> std::result::Result { - self.backend - .external_source_snapshot(ExternalSourceSnapshotRequest { - workspace_path: self.workspace_path_string(), - force_refresh, - }) + ) -> std::result::Result { + let scope = self + .local_management_scope("External source management") + .map_err(external_source_management_error)?; + self.ensure_external_source_subscription().await?; + self.management + .external_source + .snapshot(&scope, force_refresh) .await - .map_err(external_source_backend_error) + .map_err(external_source_management_error) } pub(crate) fn subscribe_external_source_updates( &self, ) -> Result> { - shared_receiver( - &self.external_source_events, - "App Server external source event stream is unavailable", - ) + Ok(self.external_source_events.subscribe()) } pub(crate) async fn external_source_control( &self, request: ExternalSourceControlRequestV1, - ) -> std::result::Result { - let operation_id = request.operation_id.clone(); - self.backend - .external_source_control(ExternalSourceControlRequest { - workspace_path: self.workspace_path_string(), - request, - }) + ) -> std::result::Result { + let scope = self + .local_management_scope("External source management") + .map_err(external_source_management_error)?; + self.management + .external_source + .control(&scope, request) .await - .map_err(|error| external_source_backend_error_with_id(error, Some(&operation_id))) + .map_err(external_source_management_error) } pub(crate) async fn external_source_review( &self, action: ExternalSourceReviewAction, - ) -> std::result::Result { + ) -> std::result::Result { + let scope = self + .local_management_scope("External source management") + .map_err(external_source_management_error)?; let operation_id = format!("tui-{}", uuid::Uuid::new_v4()); - self.backend - .external_source_review(ExternalSourceReviewRequest { - workspace_path: self.workspace_path_string(), - operation_id: operation_id.clone(), - action, - }) + self.management + .external_source + .review(&scope, &operation_id, action) .await - .map(|response| response.0) - .map_err(|error| external_source_backend_error_with_id(error, Some(&operation_id))) + .map_err(external_source_management_error) } pub(crate) async fn set_native_command_choice( @@ -507,20 +291,29 @@ impl TuiAgentClient { native_commands: Vec, selected_candidate_id: String, expected_preference_revision: u64, - ) -> std::result::Result { + ) -> std::result::Result { + let scope = self + .local_management_scope("External command management") + .map_err(external_source_management_error)?; let operation_id = format!("tui-{}", uuid::Uuid::new_v4()); - self.backend - .set_native_command_choice(SetNativeCommandChoiceRequest { - workspace_path: self.workspace_path_string(), - operation_id: operation_id.clone(), + self.management + .external_command + .set_native_choice( + &scope, + &operation_id, native_commands, - selected_candidate_id, + &selected_candidate_id, expected_preference_revision, - }) + ) .await - .map_err(|error| external_source_backend_error_with_id(error, Some(&operation_id))) + .map(|(conflicts, preferences)| NativeCommandChoiceView { + conflicts, + preferences, + }) + .map_err(external_source_management_error) } + #[allow(clippy::too_many_arguments)] pub(crate) async fn expand_external_command( &self, command_name: String, @@ -531,114 +324,110 @@ impl TuiAgentClient { native_conflict_key: Option, expected_preference_revision: Option, shell_review_decision: Option, - ) -> std::result::Result { - let operation_id = format!("tui-{}", uuid::Uuid::new_v4()); - self.backend - .expand_external_command(ExpandExternalCommandRequest { - workspace_path: self.workspace_path_string(), - operation_id: operation_id.clone(), - command_name, - arguments, - native_commands, - candidate_id, - content_version, - native_conflict_key, - expected_preference_revision, - shell_review_decision, - }) + ) -> std::result::Result { + let scope = self + .local_management_scope("External command management") + .map_err(external_source_management_error)?; + self.management + .external_command + .expand( + &scope, + ExternalCommandExpansionRequest { + operation_id: format!("tui-{}", uuid::Uuid::new_v4()), + command_name, + arguments, + native_commands, + candidate_id, + content_version, + native_conflict_key, + expected_preference_revision, + shell_review_decision, + }, + ) .await - .map_err(|error| external_source_backend_error_with_id(error, Some(&operation_id))) + .map_err(external_source_management_error) } pub(crate) async fn native_hook_overview( &self, - ) -> std::result::Result { - self.backend - .native_hook_overview(NativeHookOverviewRequest { - workspace_path: self.project_workspace_path_string(), - }) + ) -> std::result::Result { + let scope = self + .local_management_scope("Native hook management") + .map_err(external_source_management_error)?; + self.management + .native_hook + .overview(&scope) .await - .map(|response| response.0) - .map_err(external_source_backend_error) + .map_err(external_source_management_error) } pub(crate) async fn external_hook_snapshot( &self, refresh_updates: bool, - ) -> std::result::Result< - bitfun_product_domains::external_hook_import::ExternalHookImportSnapshotV1, - ExternalSourceOperationError, - > { - self.backend - .external_hook_snapshot(ExternalHookSnapshotRequest { - workspace_path: self.project_workspace_path_string(), - refresh_updates, - }) + ) -> std::result::Result { + let scope = self + .local_management_scope("External hook management") + .map_err(external_source_management_error)?; + self.management + .external_hook + .snapshot(&scope, refresh_updates) .await - .map(|response| response.0) - .map_err(external_source_backend_error) + .map_err(external_source_management_error) } pub(crate) async fn external_hook_plan( &self, - source: bitfun_product_domains::external_sources::SourceKey, - ) -> std::result::Result< - bitfun_product_domains::external_hook_import::ExternalHookImportPlanV1, - ExternalSourceOperationError, - > { - self.backend - .external_hook_plan(ExternalHookPlanRequest { - workspace_path: self.project_workspace_path_string(), - source, - }) + source: SourceKey, + ) -> std::result::Result { + let scope = self + .local_management_scope("External hook management") + .map_err(external_source_management_error)?; + self.management + .external_hook + .plan(&scope, source) .await - .map(|response| response.0) - .map_err(external_source_backend_error) + .map_err(external_source_management_error) } pub(crate) async fn external_hook_apply( &self, - import_request: bitfun_product_domains::external_hook_import::ExternalHookImportApplyRequestV1, - ) -> std::result::Result< - bitfun_product_domains::external_hook_import::ExternalHookImportApplyResultV1, - ExternalSourceOperationError, - > { - self.backend - .external_hook_apply(ExternalHookApplyRequest { - workspace_path: self.project_workspace_path_string(), - operation_id: format!("tui-hook-{}", uuid::Uuid::new_v4()), - import_request, - }) + request: ExternalHookImportApplyRequestV1, + ) -> std::result::Result { + let scope = self + .local_management_scope("External hook management") + .map_err(external_source_management_error)?; + self.management + .external_hook + .apply( + &scope, + &format!("tui-hook-{}", uuid::Uuid::new_v4()), + request, + ) .await - .map(|response| response.0) - .map_err(external_source_backend_error) + .map_err(external_source_management_error) } pub(crate) async fn external_hook_mutate( &self, - mutation: bitfun_product_domains::external_hook_import::ExternalHookImportMutationRequestV1, - ) -> std::result::Result< - bitfun_product_domains::external_hook_import::ExternalHookImportSnapshotV1, - ExternalSourceOperationError, - > { - self.backend - .external_hook_mutate(ExternalHookMutationRequest { - workspace_path: self.project_workspace_path_string(), - operation_id: format!("tui-hook-{}", uuid::Uuid::new_v4()), - mutation, - }) + request: ExternalHookImportMutationRequestV1, + ) -> std::result::Result { + let scope = self + .local_management_scope("External hook management") + .map_err(external_source_management_error)?; + self.management + .external_hook + .mutate( + &scope, + &format!("tui-hook-{}", uuid::Uuid::new_v4()), + request, + ) .await - .map(|response| response.0) - .map_err(external_source_backend_error) + .map_err(external_source_management_error) } pub(crate) async fn account_snapshot(&self) -> Result { - self.backend - .account_snapshot(AccountSnapshotRequest { - workspace_path: self.project_workspace_path_string(), - }) - .await - .map_err(worktree_operation_error) + let scope = self.management_scope(); + Ok(self.management.account.snapshot(&scope).await?) } pub(crate) async fn account_login( @@ -646,109 +435,165 @@ impl TuiAgentClient { relay_url: String, username: String, password: String, - ) -> Result { - self.backend - .account_login(AccountLoginRequest { - operation_id: account_operation_id(), - relay_url, - username, - password, - }) - .await - .map_err(worktree_operation_error) + ) -> Result { + let scope = self.management_scope(); + Ok(self + .management + .account + .login( + &scope, + &account_operation_id(), + &relay_url, + &username, + &password, + ) + .await?) } pub(crate) async fn account_finalize_login( &self, choice: AccountSyncChoice, ) -> Result { - self.backend - .account_finalize_login(AccountFinalizeLoginRequest { - operation_id: account_operation_id(), - choice, - workspace_path: self.project_workspace_path_string(), - }) - .await - .map_err(worktree_operation_error) + let scope = self.management_scope(); + Ok(self + .management + .account + .finalize_login( + &scope, + account_operation_id(), + choice == AccountSyncChoice::Local, + self.project_workspace_path_buf(), + ) + .await?) } pub(crate) async fn account_logout(&self) -> Result { - self.backend - .account_logout(AccountLogoutRequest { - operation_id: account_operation_id(), - workspace_path: self.project_workspace_path_string(), - }) - .await - .map_err(Into::into) + let scope = self.management_scope(); + Ok(self + .management + .account + .logout(&scope, &account_operation_id()) + .await?) } pub(crate) async fn settings_sync_start( &self, is_first_login: bool, ) -> Result { - self.backend - .settings_sync_start(SettingsSyncStartRequest { - operation_id: account_operation_id(), - workspace_path: self.project_workspace_path_string(), - is_first_login, - }) - .await - .map_err(Into::into) + let scope = self.management_scope(); + Ok(SettingsSyncResponse { + progress: self + .management + .settings_sync + .start( + &scope, + account_operation_id(), + is_first_login, + self.project_workspace_path_buf(), + ) + .await?, + }) } pub(crate) async fn settings_sync_snapshot(&self) -> Result { - self.backend - .settings_sync_snapshot(SettingsSyncSnapshotRequest { - workspace_path: self.project_workspace_path_string(), - }) - .await - .map_err(Into::into) + let scope = self.management_scope(); + Ok(SettingsSyncResponse { + progress: self.management.settings_sync.snapshot(&scope).await?, + }) } pub(crate) async fn settings_sync_cancel(&self) -> Result { - self.backend - .settings_sync_cancel(SettingsSyncCancelRequest { - operation_id: account_operation_id(), - workspace_path: self.project_workspace_path_string(), - }) - .await - .map_err(Into::into) + let scope = self.management_scope(); + Ok(SettingsSyncResponse { + progress: self + .management + .settings_sync + .cancel(&scope, &account_operation_id()) + .await?, + }) } pub(crate) async fn settings_sync_local_changed(&self) -> Result { - self.backend - .settings_sync_local_changed(SettingsSyncLocalChangedRequest { - operation_id: account_operation_id(), - workspace_path: self.project_workspace_path_string(), - }) + let scope = self.management_scope(); + Ok(SettingsSyncResponse { + progress: self + .management + .settings_sync + .local_changed(&scope, &account_operation_id()) + .await?, + }) + } + + pub(crate) async fn worktree_repository_status( + &self, + workspace_path: String, + ) -> Result { + let scope = management_scope( + PathBuf::from(workspace_path), + self.runtime.is_remote_workspace(), + ); + Ok(self.management.worktree.repository_status(&scope).await?) + } + + pub(crate) async fn worktree_bind_session( + &self, + session_id: String, + project_workspace_path: Option, + ) -> Result { + self.worktree_transition(session_id, project_workspace_path, true) + .await + } + + pub(crate) async fn worktree_release_session( + &self, + session_id: String, + project_workspace_path: Option, + ) -> Result { + self.worktree_transition(session_id, project_workspace_path, false) .await - .map_err(Into::into) + } + + async fn worktree_transition( + &self, + session_id: String, + project_workspace_path: Option, + enabled: bool, + ) -> Result { + let workspace = project_workspace_path + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| self.project_workspace_path_buf()); + let request = WorktreeBindingRequest { + scope: management_scope(workspace, self.runtime.is_remote_workspace()), + operation_id: worktree_operation_id(), + session_id, + project_workspace_path, + }; + Ok(if enabled { + self.management.worktree.bind(request).await? + } else { + self.management.worktree.release(request).await? + }) } pub(crate) fn subscribe_events(&self) -> Result> { - shared_receiver( - &self.agent_events, - "App Server agent event stream is unavailable", - ) + self.runtime + .subscribe_events() + .map_err(|error| anyhow::anyhow!(error.into_message())) } pub(crate) fn subscribe_permission_requests( &self, ) -> Result> { - shared_receiver( - &self.permission_events, - "App Server permission event stream is unavailable", - ) + self.runtime + .subscribe_permission_requests() + .map_err(|error| anyhow::anyhow!(error.into_message())) } pub(crate) fn pending_permission_requests(&self) -> Result> { - Ok(self - .pending_permissions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .values() - .cloned() - .collect()) + self.runtime + .pending_permission_requests() + .map_err(|error| anyhow::anyhow!(error.into_message())) } pub(crate) async fn respond_permission( @@ -756,173 +601,53 @@ impl TuiAgentClient { request_id: &str, reply: PermissionReply, ) -> Result<()> { - self.backend - .respond_permission(RespondPermissionRequest { - request_id: request_id.to_string(), - reply, - }) - .await?; - self.pending_permissions - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(request_id); - Ok(()) + self.runtime.respond_permission(request_id, reply).await } pub(crate) fn set_approval_policy(&self, policy: CliApprovalPolicy) { - *self - .approval_policy - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = policy; - } - - fn approval_policy(&self) -> CliApprovalPolicy { - *self - .approval_policy - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - pub(crate) fn workspace_path_buf(&self) -> PathBuf { - self.workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .execution() + self.runtime.set_approval_policy(policy); } pub(crate) fn workspace_path_string(&self) -> String { - self.workspace_path_buf().to_string_lossy().to_string() + self.runtime.workspace_path_string() } pub(crate) fn project_workspace_path_string(&self) -> String { - self.workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .project() - .to_string_lossy() - .to_string() - } - - pub(crate) fn set_workspace_binding(&self, binding: &AgentSessionWorkspaceBinding) { - self.workspace_paths - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .apply_binding(binding); - } - - pub(crate) async fn worktree_repository_status( - &self, - workspace_path: String, - ) -> Result { - let paths = self - .workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - self.backend - .worktree_repository_status(WorktreeRepositoryStatusRequest { - workspace_path, - remote_connection_id: paths.remote_connection_id.clone(), - remote_ssh_host: paths.remote_ssh_host.clone(), - }) - .await - .map_err(Into::into) + self.runtime.project_workspace_path_string() } - pub(crate) async fn worktree_bind_session( - &self, - session_id: String, - project_workspace_path: Option, - ) -> Result { - let (remote_connection_id, remote_ssh_host) = self.remote_workspace_scope(); - self.backend - .worktree_bind_session(WorktreeBindSessionRequest { - operation_id: worktree_operation_id(), - session_id, - project_workspace_path, - remote_connection_id, - remote_ssh_host, - }) - .await - .map_err(Into::into) + fn project_workspace_path_buf(&self) -> PathBuf { + self.runtime.project_workspace_path_buf() } - pub(crate) async fn worktree_release_session( - &self, - session_id: String, - project_workspace_path: Option, - ) -> Result { - let (remote_connection_id, remote_ssh_host) = self.remote_workspace_scope(); - self.backend - .worktree_release_session(WorktreeReleaseSessionRequest { - operation_id: worktree_operation_id(), - session_id, - project_workspace_path, - remote_connection_id, - remote_ssh_host, - }) - .await - .map_err(Into::into) - } - - fn remote_workspace_scope(&self) -> (Option, Option) { - let paths = self - .workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - ( - paths.remote_connection_id.clone(), - paths.remote_ssh_host.clone(), - ) + pub(crate) fn set_workspace_binding(&self, binding: &AgentSessionWorkspaceBinding) { + self.runtime.set_workspace_binding(binding); } pub(crate) async fn list_sessions(&self) -> Result> { - Ok(self - .backend - .list_sessions(ListSessionsRequest(AgentSessionListRequest { - workspace_path: self.project_workspace_path_string(), - remote_connection_id: None, - remote_ssh_host: None, - })) - .await? - .sessions) + self.runtime.list_sessions().await } pub(crate) async fn session_lineage( &self, root_session_id: &str, ) -> Result> { - Ok(self - .backend - .session_lineage(SessionLineageRequest(AgentSessionLineageRequest { - workspace_path: self.project_workspace_path_string(), - anchor_session_id: root_session_id.to_string(), - remote_connection_id: None, - remote_ssh_host: None, - })) - .await? - .0) + self.runtime.session_lineage(root_session_id).await } pub(crate) async fn inspect_lineage_session( &self, root_session_id: &str, - session_id: &str, + target_session_id: &str, required_settled_turn_ids: &[String], ) -> std::result::Result { - self.backend - .inspect_lineage(InspectLineageRequest( - AgentSessionLineageTranscriptRequest { - workspace_path: self.project_workspace_path_string(), - root_session_id: root_session_id.to_string(), - session_id: session_id.to_string(), - required_settled_turn_ids: required_settled_turn_ids.to_vec(), - remote_connection_id: None, - remote_ssh_host: None, - }, - )) + self.runtime + .inspect_lineage_session( + root_session_id, + target_session_id, + required_settled_turn_ids, + ) .await - .map(|response| response.0) - .map_err(SessionOperationError::backend) } pub(crate) async fn cancel_lineage_session( @@ -931,108 +656,25 @@ impl TuiAgentClient { session_id: &str, expected_active_turn_id: &str, ) -> Result { - Ok(self - .backend - .cancel_lineage(CancelLineageRequest( - AgentSessionLineageCancellationRequest { - workspace_path: self.project_workspace_path_string(), - root_session_id: root_session_id.to_string(), - session_id: session_id.to_string(), - expected_active_turn_id: Some(expected_active_turn_id.to_string()), - source: Some(AgentSubmissionSource::Cli), - reason: Some("user_cancelled".to_string()), - wait_timeout_ms: Some(5_000), - remote_connection_id: None, - remote_ssh_host: None, - }, - )) - .await? - .0) + self.runtime + .cancel_lineage_session(root_session_id, session_id, expected_active_turn_id) + .await } pub(crate) async fn restore_session_in_current_workspace( &self, session_id: &str, - ) -> Result<( - AgentSessionSummary, - AgentSessionWorkspaceBinding, - Vec, - SessionTranscript, - )> { - let previous = self - .list_sessions() - .await? - .into_iter() - .find(|summary| summary.session_id == session_id) - .ok_or_else(|| anyhow::anyhow!("Session {session_id} was not found"))?; - let response = self - .backend - .sync_session(SyncSessionRequest { - workspace_path: self.project_workspace_path_string(), - session_id: session_id.to_string(), - include_internal: false, - remote_connection_id: None, - remote_ssh_host: None, - }) - .await?; - self.set_workspace_binding(&response.workspace_binding); - *self.session_id.lock().await = Some(session_id.to_string()); - *self.current_turn_id.lock().await = match &response.state { - SessionRuntimeState::Processing { - current_turn_id, .. - } => Some(current_turn_id.clone()), - SessionRuntimeState::Idle | SessionRuntimeState::Error { .. } => None, - }; - self.replace_pending_permissions(response.pending_permissions.clone()); - let notices = session_migration_notices(&previous, &response.session); - Ok(( - response.session, - response.workspace_binding, - notices, - response.transcript, - )) - } - - pub(crate) async fn session_workspace_binding( - &self, - session_id: &str, - ) -> Result { - if self.session_id.lock().await.as_deref() != Some(session_id) { - return Err(anyhow::anyhow!("Session {session_id} is not attached")); - } - let paths = self - .workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let execution = paths.execution(); - Ok(AgentSessionWorkspaceBinding { - workspace_id: paths.workspace_id.clone(), - workspace_path: execution.to_string_lossy().to_string(), - project_workspace_path: Some(paths.project().to_string_lossy().to_string()), - execution_target: paths.execution_target.clone().or_else(|| { - Some(SessionExecutionTarget::local( - execution.to_string_lossy().to_string(), - )) - }), - remote_connection_id: paths.remote_connection_id.clone(), - remote_ssh_host: paths.remote_ssh_host.clone(), - }) + ) -> Result { + self.runtime + .restore_session_in_current_workspace(session_id) + .await } pub(crate) async fn delete_session( &self, session_id: &str, ) -> std::result::Result<(), SessionOperationError> { - self.backend - .delete_session(DeleteSessionRequest(AgentSessionDeleteRequest { - workspace_path: self.project_workspace_path_string(), - session_id: session_id.to_string(), - remote_connection_id: None, - remote_ssh_host: None, - })) - .await - .map(|_| ()) - .map_err(SessionOperationError::backend) + self.runtime.delete_session(session_id).await } pub(crate) async fn update_session_model( @@ -1040,14 +682,9 @@ impl TuiAgentClient { session_id: &str, model_id: &str, ) -> std::result::Result<(), SessionOperationError> { - self.backend - .update_session_model(UpdateSessionModelRequest(AgentSessionModelUpdateRequest { - session_id: session_id.to_string(), - model_id: model_id.to_string(), - })) + self.runtime + .update_session_model(session_id, model_id) .await - .map(|_| ()) - .map_err(SessionOperationError::backend) } pub(crate) async fn rename_session( @@ -1055,17 +692,7 @@ impl TuiAgentClient { session_id: &str, session_name: &str, ) -> std::result::Result<(), SessionOperationError> { - self.backend - .rename_session(RenameSessionRequest(AgentSessionRenameRequest { - workspace_path: self.project_workspace_path_string(), - session_id: session_id.to_string(), - session_name: session_name.to_string(), - remote_connection_id: None, - remote_ssh_host: None, - })) - .await - .map(|_| ()) - .map_err(SessionOperationError::backend) + self.runtime.rename_session(session_id, session_name).await } pub(crate) async fn update_session_mode( @@ -1073,14 +700,7 @@ impl TuiAgentClient { session_id: &str, mode_id: &str, ) -> std::result::Result<(), SessionOperationError> { - self.backend - .update_session_mode(UpdateSessionModeRequest(AgentSessionModeUpdateRequest { - session_id: session_id.to_string(), - mode_id: mode_id.to_string(), - })) - .await - .map(|_| ()) - .map_err(SessionOperationError::backend) + self.runtime.update_session_mode(session_id, mode_id).await } pub(crate) async fn fork_current_session( @@ -1091,119 +711,38 @@ impl TuiAgentClient { AgentSessionWorkspaceBinding, SessionTranscript, )> { - let source_session_id = self.require_session_id().await?; - let forked = match before_turn_id { - Some(source_turn_id) => { - self.backend - .fork_session_before_turn(ForkSessionBeforeTurnRequest( - bitfun_runtime_ports::AgentSessionForkBeforeTurnRequest { - workspace_path: self.project_workspace_path_string(), - source_session_id, - source_turn_id: source_turn_id.to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }, - )) - .await? - } - None => { - self.backend - .fork_session(ForkSessionRequest( - bitfun_runtime_ports::AgentSessionForkRequest { - workspace_path: self.project_workspace_path_string(), - source_session_id, - remote_connection_id: None, - remote_ssh_host: None, - }, - )) - .await? - } - }; - let new_session_id = forked.0.session_id; - let (summary, binding, _, transcript) = self - .restore_session_in_current_workspace(&new_session_id) - .await?; - Ok((summary, binding, transcript)) + self.runtime.fork_current_session(before_turn_id).await } pub(crate) async fn revert_current_session( &self, undo: bool, ) -> Result { - let session_id = self.require_session_id().await?; - let request = AgentSessionRevertRequest { - workspace_path: self.project_workspace_path_string(), - session_id: session_id.clone(), - remote_connection_id: None, - remote_ssh_host: None, - }; - let mut result = if undo { - self.backend - .undo_session(UndoSessionRequest(request)) - .await? - .0 - } else { - self.backend - .redo_session(RedoSessionRequest(request)) - .await? - .0 - }; - if let Some(turn_id) = self.current_turn_id.lock().await.take() { - if !result.retired_turn_ids.contains(&turn_id) { - result.retired_turn_ids.push(turn_id); - } - } - Ok(result) + self.runtime.revert_current_session(undo).await } pub(crate) async fn workspace_diff(&self) -> Result { - if let Some(reason) = self - .workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .workspace_diff_unavailable_reason() - { - return Err(anyhow::anyhow!(reason)); - } - Ok(self.backend.workspace_diff().await?.0) + self.runtime.workspace_diff().await } pub(crate) async fn generate_session_usage_report( &self, request: AgentSessionUsageRequest, ) -> Result { - Ok(self - .backend - .session_usage(SessionUsageRequest(request)) - .await? - .0) + self.runtime.generate_session_usage_report(request).await } pub(crate) async fn reload_context(&self, request: AgentContextReloadRequest) -> Result<()> { - self.backend - .reload_context(ReloadContextRequest(request)) - .await?; - Ok(()) + self.runtime.reload_context(request).await } - pub(crate) async fn wait_for_turn_settlement( + pub(crate) async fn ensure_session_with_workspace_binding( &self, - session_id: &str, - turn_id: &str, - wait_timeout_ms: u64, - ) -> Result<()> { - self.backend - .wait_for_settlement(WaitForSettlementRequest(AgentTurnSettlementRequest { - session_id: session_id.to_string(), - turn_id: turn_id.to_string(), - wait_timeout_ms, - })) - .await?; - Ok(()) - } - - pub(crate) async fn ensure_session(&self, agent_type: &str) -> Result { - self.ensure_session_with_model(agent_type, None).await + agent_type: &str, + ) -> Result<(String, AgentSessionWorkspaceBinding)> { + self.runtime + .ensure_session_with_workspace_binding(agent_type) + .await } pub(crate) async fn ensure_session_with_model( @@ -1211,94 +750,22 @@ impl TuiAgentClient { agent_type: &str, model_id: Option, ) -> Result { - if let Some(id) = self.session_id.lock().await.clone() { - return Ok(id); - } - self.create_session(agent_type, model_id, false).await - } - - pub(crate) async fn create_new_session(&self, agent_type: &str) -> Result { - self.create_session(agent_type, None, true).await + self.runtime + .ensure_session_with_model(agent_type, model_id) + .await } - async fn create_session( + pub(crate) async fn create_new_session_with_workspace_binding( &self, agent_type: &str, - model_id: Option, - reset_to_project: bool, - ) -> Result { - let workspace = if reset_to_project { - self.workspace_paths - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .reset_execution_to_project() - } else { - self.workspace_path_buf() - }; - let project = self.project_workspace_path_string(); - let response = self - .backend - .create_session(CreateSessionRequest(AgentSessionCreateRequest { - session_name: default_session_name(), - agent_type: agent_type.to_string(), - workspace_path: Some(workspace.to_string_lossy().to_string()), - project_workspace_path: Some(project.clone()), - execution_target: Some(SessionExecutionTarget::local( - workspace.to_string_lossy().to_string(), - )), - workspace_id: None, - remote_connection_id: None, - remote_ssh_host: None, - model_id, - metadata: serde_json::Map::new(), - })) - .await?; - let session = response.0; - let id = session.session_id.clone(); - let binding = AgentSessionWorkspaceBinding { - workspace_id: session.workspace_id, - workspace_path: session - .workspace_path - .unwrap_or_else(|| workspace.to_string_lossy().to_string()), - project_workspace_path: session.project_workspace_path.or(Some(project)), - execution_target: session.execution_target, - remote_connection_id: None, - remote_ssh_host: None, - }; - self.set_workspace_binding(&binding); - *self.session_id.lock().await = Some(id.clone()); - *self.current_turn_id.lock().await = None; - self.replace_pending_permissions(Vec::new()); - Ok(id) + ) -> Result<(String, AgentSessionWorkspaceBinding)> { + self.runtime + .create_new_session_with_workspace_binding(agent_type) + .await } pub(crate) async fn start_session_compaction(&self, session_id: &str) -> Result { - let turn_id = uuid::Uuid::new_v4().to_string(); - *self.current_turn_id.lock().await = Some(turn_id.clone()); - let result = self - .backend - .compact_session(CompactSessionRequest(AgentSessionCompactionRequest { - session_id: session_id.to_string(), - turn_id: turn_id.clone(), - })) - .await; - match result { - Ok(response) - if response.0.session_id == session_id && response.0.turn_id == turn_id => - { - Ok(turn_id) - } - Ok(_) => { - *self.current_turn_id.lock().await = None; - Err(anyhow::anyhow!( - "App Server accepted compaction with an unexpected identity" - )) - } - Err(error) => { - *self.current_turn_id.lock().await = None; - Err(error.into()) - } - } + self.runtime.start_session_compaction(session_id).await } pub(crate) async fn send_message_with_context( @@ -1308,15 +775,9 @@ impl TuiAgentClient { attachments: Vec, agent_type: &str, ) -> Result { - self.submit_dialog_turn( - message, - None, - workspace_references, - attachments, - AgentDialogTurnExecution::Standard, - agent_type, - ) - .await + self.runtime + .send_message_with_context(message, workspace_references, attachments, agent_type) + .await } pub(crate) async fn send_external_subagent_command( @@ -1327,77 +788,15 @@ impl TuiAgentClient { logical_id: String, agent_type: &str, ) -> Result { - self.submit_dialog_turn( - prompt, - Some(original_command), - Vec::new(), - Vec::new(), - AgentDialogTurnExecution::FreshExternalSubagent { + self.runtime + .send_external_subagent_command( + prompt, + original_command, ecosystem_id, logical_id, - }, - agent_type, - ) - .await - } - - async fn submit_dialog_turn( - &self, - message: String, - original_message: Option, - workspace_references: Vec, - attachments: Vec, - execution: AgentDialogTurnExecution, - agent_type: &str, - ) -> Result { - let session_id = self.ensure_session(agent_type).await?; - let turn_id = uuid::Uuid::new_v4().to_string(); - *self.current_turn_id.lock().await = Some(turn_id.clone()); - let mut metadata = approval_metadata(self.approval_policy()); - put_agent_workspace_references(&mut metadata, &workspace_references) - .map_err(|error| anyhow::anyhow!(error.message))?; - let result = self - .backend - .submit_dialog_turn(SubmitDialogTurnRequest::from(AgentDialogTurnRequest { - session_id: session_id.clone(), - message, - original_message, - turn_id: Some(turn_id.clone()), - execution, - agent_type: agent_type.to_string(), - workspace_path: Some(self.project_workspace_path_string()), - remote_connection_id: None, - remote_ssh_host: None, - policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), - reply_route: None, - prepended_reminders: Vec::new(), - attachments, - metadata, - })) - .await; - match result { - Ok(SubmitDialogTurnResponse::Started { - session_id: accepted_session, - turn_id: accepted_turn, - }) - | Ok(SubmitDialogTurnResponse::Queued { - session_id: accepted_session, - turn_id: accepted_turn, - }) if accepted_session == session_id => { - *self.current_turn_id.lock().await = Some(accepted_turn.clone()); - Ok(accepted_turn) - } - Ok(_) => { - *self.current_turn_id.lock().await = None; - Err(anyhow::anyhow!( - "App Server accepted a turn with an unexpected identity" - )) - } - Err(error) => { - *self.current_turn_id.lock().await = None; - Err(error.into()) - } - } + agent_type, + ) + .await } pub(crate) async fn steer_current_turn( @@ -1405,25 +804,9 @@ impl TuiAgentClient { content: String, display_content: Option, ) -> Result { - let session_id = self.require_session_id().await?; - let turn_id = self - .current_turn_id - .lock() + self.runtime + .steer_current_turn(content, display_content) .await - .clone() - .ok_or_else(|| anyhow::anyhow!("No active turn is available for steering"))?; - Ok(self - .backend - .steer_turn(SteerTurnRequest(AgentDialogSteerRequest { - session_id, - turn_id, - content, - display_content, - attachments: Vec::new(), - metadata: serde_json::Map::new(), - })) - .await? - .steering_id) } pub(crate) async fn run_user_shell_command( @@ -1431,51 +814,16 @@ impl TuiAgentClient { command: String, agent_type: &str, ) -> Result { - let session_id = self.ensure_session(agent_type).await?; - let turn_id = uuid::Uuid::new_v4().to_string(); - *self.current_turn_id.lock().await = Some(turn_id.clone()); - let response = self - .backend - .run_user_shell_command(RunUserShellCommandRequest(AgentUserShellCommandRequest { - session_id: session_id.clone(), - turn_id: turn_id.clone(), - command, - })) - .await; - match response { - Ok(response) - if response.0.session_id == session_id && response.0.turn_id == turn_id => - { - Ok(turn_id) - } - Ok(_) => { - *self.current_turn_id.lock().await = None; - Err(anyhow::anyhow!( - "App Server accepted a Shell command with an unexpected identity" - )) - } - Err(error) => { - *self.current_turn_id.lock().await = None; - Err(error.into()) - } - } + self.runtime + .run_user_shell_command(command, agent_type) + .await } pub(crate) async fn search_workspace_references( &self, query: String, ) -> Result { - Ok(self - .backend - .search_workspace_references(SearchWorkspaceReferencesRequest( - AgentWorkspaceReferenceSearchRequest { - session_id: self.require_session_id().await?, - query, - limit: 20, - }, - )) - .await? - .0) + self.runtime.search_workspace_references(query).await } pub(crate) async fn workspace_references_for_message( @@ -1483,284 +831,128 @@ impl TuiAgentClient { session_id: String, message_id: String, ) -> Result> { - Ok(self - .backend - .message_references(MessageReferencesRequest( - AgentMessageWorkspaceReferencesRequest { - session_id, - message_id, - }, - )) - .await? - .0) + self.runtime + .workspace_references_for_message(session_id, message_id) + .await } pub(crate) async fn cancel_current_turn(&self) -> Result<()> { - let session_id = self.session_id.lock().await.clone(); - let turn_id = self.current_turn_id.lock().await.clone(); - if let (Some(session_id), Some(turn_id)) = (session_id, turn_id) { - self.backend - .cancel_turn(CancelTurnRequest(AgentTurnCancellationRequest { - session_id, - turn_id: Some(turn_id.clone()), - source: Some(AgentSubmissionSource::Cli), - requester_session_id: None, - reason: Some("user_cancelled".to_string()), - wait_timeout_ms: None, - cancel_descendants: true, - })) - .await?; - let mut current = self.current_turn_id.lock().await; - if current.as_deref() == Some(turn_id.as_str()) { - *current = None; - } - } - Ok(()) + self.runtime.cancel_current_turn().await } pub(crate) async fn submit_user_answers( &self, - tool_id: &str, + session_id: String, + turn_id: String, + tool_id: String, + registration_sequence: u64, answers: serde_json::Value, ) -> Result<()> { - self.backend - .submit_user_answers(SubmitUserAnswersRequest { - tool_id: tool_id.to_string(), - answers, - }) - .await?; - Ok(()) + self.runtime + .submit_user_answers(session_id, turn_id, tool_id, registration_sequence, answers) + .await } - async fn require_session_id(&self) -> Result { - self.session_id + async fn ensure_external_source_subscription( + &self, + ) -> std::result::Result<(), ExternalSourceOperationError> { + let scope = self + .local_management_scope("External source management") + .map_err(external_source_management_error)?; + let workspace = scope.workspace_path; + let workspace_key = workspace.to_string_lossy().to_string(); + { + let mut subscriptions = self + .external_source_subscriptions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !subscriptions.insert(workspace_key.clone()) { + return Ok(()); + } + } + let mut subscription = + match bitfun_core::external_sources::subscribe_external_source_updates(Some(&workspace)) + .await + { + Ok(subscription) => subscription, + Err(error) => { + self.external_source_subscriptions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&workspace_key); + return Err( + bitfun_core::external_sources::sanitize_external_source_operation_error( + error, + ), + ); + } + }; + let sender = self.external_source_events.clone(); + let subscriptions = self.external_source_subscriptions.clone(); + let task = tokio::spawn(async move { + loop { + match subscription.recv().await { + Ok(snapshot) => { + let _ = sender.send(( + workspace_key.clone(), + ExternalSourcePublicSnapshot::from(snapshot), + )); + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + subscriptions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&workspace_key); + }); + self.external_source_tasks .lock() - .await - .clone() - .ok_or_else(|| anyhow::anyhow!("No active Session")) - } - - fn replace_pending_permissions(&self, requests: Vec) { - let mut pending = self - .pending_permissions - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - pending.clear(); - pending.extend( - requests - .into_iter() - .map(|request| (request.request_id.clone(), request)), - ); + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(task); + Ok(()) } } -fn external_source_backend_error(error: TuiBackendError) -> ExternalSourceOperationError { - external_source_backend_error_with_id(error, None) -} - -fn external_source_backend_error_with_id( - error: TuiBackendError, - operation_id: Option<&str>, -) -> ExternalSourceOperationError { - if let Some(decoded) = ExternalSourceOperationError::decode(&error.message) { - return attach_operation_id(decoded, operation_id); +impl Drop for TuiAgentClient { + fn drop(&mut self) { + for task in self + .external_source_tasks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .drain(..) + { + task.abort(); + } } - let code = if error.outcome_unknown { - ExternalSourceOperationErrorCode::Timeout - } else if matches!(error.kind, TuiBackendErrorKind::Unsupported { .. }) { - ExternalSourceOperationErrorCode::HostCapabilityUnavailable - } else { - ExternalSourceOperationErrorCode::Internal - }; - let message = error.message; - let retryable = error.outcome_unknown; - attach_operation_id( - ExternalSourceOperationError::new(code, message, retryable).with_default_recovery_actions(), - operation_id, - ) } -fn attach_operation_id( - mut error: ExternalSourceOperationError, - operation_id: Option<&str>, -) -> ExternalSourceOperationError { - if let Some(operation_id) = operation_id.filter(|id| !id.is_empty()) { - if error.correlation_id.is_none() { - error = error.with_correlation_id(operation_id); - } else if error.causation_id.is_none() { - error = error.with_causation_id(operation_id); - } +fn management_scope(workspace_path: PathBuf, remote: bool) -> ManagementScope { + if remote { + ManagementScope::remote(workspace_path) + } else { + ManagementScope::local(workspace_path) } - error } fn account_operation_id() -> String { format!("tui-account-{}", uuid::Uuid::new_v4()) } -fn worktree_operation_error(error: TuiBackendError) -> anyhow::Error { - if let Some(worktree) = WorktreeOperationError::decode(&error.message) { - let recovery = worktree - .recovery_path - .as_deref() - .map(|path| format!(" Recovery path: {path}")) - .unwrap_or_default(); - return anyhow::anyhow!( - "{}: {}{}", - worktree.code.as_str(), - worktree.message, - recovery - ); - } - anyhow::anyhow!(error) -} - fn worktree_operation_id() -> String { format!("tui-worktree-{}", uuid::Uuid::new_v4()) } -fn shared_receiver( - source: &Arc>>>, - message: &str, -) -> Result> { - source - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_ref() - .map(broadcast::Sender::subscribe) - .ok_or_else(|| anyhow::anyhow!(message.to_string())) -} - -fn spawn_event_bridge( - mut source: broadcast::Receiver, - agent_sender: broadcast::Sender, - permission_sender: broadcast::Sender, - external_source_sender: broadcast::Sender<(String, ExternalSourcePublicSnapshot)>, - agent_owner: Arc>>>, - permission_owner: Arc>>>, - external_source_owner: Arc< - RwLock>>, - >, - pending: Arc>>, -) { - tokio::spawn(async move { - loop { - match source.recv().await { - Ok(AppServerEvent::Agent(notification)) => { - let _ = agent_sender.send(notification.event); - } - Ok(AppServerEvent::Permission(notification)) => { - match ¬ification.event { - PermissionRequestEvent::Asked { request } => { - pending - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert(request.request_id.clone(), request.clone()); - } - PermissionRequestEvent::Replied { request_id, .. } - | PermissionRequestEvent::Cancelled { request_id, .. } => { - pending - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(request_id); - } - } - let _ = permission_sender.send(notification.event); - } - Ok(AppServerEvent::ExternalSource(notification)) => { - let _ = external_source_sender - .send((notification.workspace_path, notification.snapshot)); - } - Ok(AppServerEvent::StreamState(notification)) - if notification.stream - == bitfun_app_server_protocol::event::EventStream::ExternalSource => - { - // The next TUI snapshot request is the authoritative recovery path. - } - Ok(AppServerEvent::StreamState(notification)) - if matches!( - notification.state, - EventStreamState::Closed | EventStreamState::Invalidated - ) => - { - send_stream_error( - &agent_sender, - notification.resync.reason.unwrap_or_else(|| { - "App Server event stream is unavailable".to_string() - }), - ); - break; - } - Ok(AppServerEvent::ConnectionClosed) - | Err(broadcast::error::RecvError::Closed) - | Err(broadcast::error::RecvError::Lagged(_)) => { - send_stream_error( - &agent_sender, - "App Server connection was lost; this view is no longer authoritative", - ); - break; - } - Ok(AppServerEvent::Config(_) | AppServerEvent::StreamState(_)) => {} - } - } - *agent_owner - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - *permission_owner - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - *external_source_owner - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - }); -} - -fn send_stream_error(sender: &broadcast::Sender, message: impl Into) { - let _ = sender.send(AgenticEventEnvelope::new( - AgenticEvent::SystemError { - session_id: None, - error: message.into(), - recoverable: false, - }, - AgenticEventPriority::Critical, - )); -} - -fn session_migration_notices( - previous: &AgentSessionSummary, - restored: &AgentSessionSummary, -) -> Vec { - let mut notices = Vec::new(); - if previous.agent_type != restored.agent_type { - notices.push(SessionMigrationNotice::Mode { - previous_id: previous.agent_type.clone(), - restored_id: restored.agent_type.clone(), - }); - } - if let (Some(previous_id), Some(restored_id)) = - (previous.model_id.as_ref(), restored.model_id.as_ref()) - { - if previous_id != restored_id { - notices.push(SessionMigrationNotice::Model { - previous_id: previous_id.clone(), - restored_id: restored_id.clone(), - }); - } +fn external_source_management_error(error: ManagementError) -> ExternalSourceOperationError { + if let Some(decoded) = ExternalSourceOperationError::decode(&error.message) { + return decoded; } - notices -} - -fn same_workspace_location(left: &Path, right: &Path) -> bool { - left == right - || dunce::canonicalize(left) - .ok() - .zip(dunce::canonicalize(right).ok()) - .is_some_and(|(left, right)| left == right) -} - -fn default_session_name() -> String { - format!( - "CLI Session - {}", - chrono::Local::now().format("%Y-%m-%d %H:%M:%S") - ) + let code = match error.kind { + ManagementErrorKind::InvalidRequest => ExternalSourceOperationErrorCode::InvalidRequest, + ManagementErrorKind::NotFound => ExternalSourceOperationErrorCode::NotFound, + ManagementErrorKind::Unsupported => ExternalSourceOperationErrorCode::Unsupported, + ManagementErrorKind::Internal => ExternalSourceOperationErrorCode::Internal, + }; + ExternalSourceOperationError::new(code, error.message, false).with_default_recovery_actions() } diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index 54c5c19da8..e47894515d 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -11,7 +11,7 @@ use bitfun_agent_runtime::sdk::{ PermissionRequest, SessionTranscript, TranscriptContent, TranscriptMessage, }; use bitfun_agent_tools::effective_tool_invocation; -use bitfun_events::ToolEventData; +use bitfun_events::{ToolEventData, ToolEventIdentity}; use bitfun_runtime_ports::{AgentSessionWorkspaceBinding, SessionExecutionTarget}; use crate::ui::permission::PermissionPrompt; @@ -415,6 +415,8 @@ pub(crate) struct ChatState { // -- Question state -- /// Current pending question prompt (if AskUserQuestion tool is waiting for answers) pub question_prompt: Option, + /// Additional routed questions, ordered by the Runtime registration facts. + question_queue: VecDeque, } impl ChatState { @@ -461,6 +463,7 @@ impl ChatState { permission_prompt: None, permission_queue: VecDeque::new(), question_prompt: None, + question_queue: VecDeque::new(), } } @@ -815,6 +818,8 @@ impl ChatState { self.is_processing = true; self.current_flow_items.clear(); self.tool_index.clear(); + self.question_prompt = None; + self.question_queue.clear(); if let Some(message) = self.messages.iter_mut().rev().find(|message| { message.role == MessageRole::Assistant && message.turn_id.as_deref() == Some(turn_id) @@ -826,11 +831,103 @@ impl ChatState { self.tool_index.insert(tool_state.tool_id.clone(), index); } } + self.rebuild_streaming_message(); } else { self.push_streaming_assistant_message(turn_id); } } + /// Restore one authoritative pending AskUserQuestion fact. Active Turn + /// transcripts are not a reliable source for questions that were emitted + /// before persistence or by a routed subagent. + pub(crate) fn restore_pending_user_input( + &mut self, + pending: &bitfun_agent_runtime::sdk::PendingUserInput, + ) -> bool { + let before = self.pending_question_count(); + self.handle_tool_event(&ToolEventData::UserInputRequested { + identity: ToolEventIdentity::direct(&pending.tool_id, "AskUserQuestion"), + registration_sequence: pending.registration_sequence, + params: pending.input.clone(), + }); + self.pending_question_count() > before + } + + fn enqueue_question_prompt(&mut self, prompt: QuestionPrompt) -> bool { + if self + .question_prompt + .iter() + .chain(self.question_queue.iter()) + .any(|queued| { + queued.session_id == prompt.session_id + && queued.turn_id == prompt.turn_id + && queued.tool_id == prompt.tool_id + && queued.registration_sequence == prompt.registration_sequence + }) + { + return false; + } + if self.question_prompt.is_none() { + self.question_prompt = Some(prompt); + } else { + self.question_queue.push_back(prompt); + } + true + } + + pub(crate) fn resolve_question_prompt(&mut self, tool_id: &str) -> bool { + if self + .question_prompt + .as_ref() + .is_some_and(|prompt| prompt.tool_id == tool_id) + { + self.question_prompt = self.question_queue.pop_front(); + return true; + } + let Some(index) = self + .question_queue + .iter() + .position(|prompt| prompt.tool_id == tool_id) + else { + return false; + }; + self.question_queue.remove(index); + true + } + + fn resolve_question_prompt_registration( + &mut self, + session_id: &str, + turn_id: &str, + tool_id: &str, + registration_sequence: u64, + ) -> bool { + let matches = |prompt: &QuestionPrompt| { + prompt.session_id == session_id + && prompt.turn_id == turn_id + && prompt.tool_id == tool_id + && prompt.registration_sequence == registration_sequence + }; + if self.question_prompt.as_ref().is_some_and(matches) { + self.question_prompt = self.question_queue.pop_front(); + return true; + } + let Some(index) = self.question_queue.iter().position(matches) else { + return false; + }; + self.question_queue.remove(index); + true + } + + pub(crate) fn clear_question_prompts(&mut self) { + self.question_prompt = None; + self.question_queue.clear(); + } + + pub(crate) fn pending_question_count(&self) -> usize { + usize::from(self.question_prompt.is_some()) + self.question_queue.len() + } + /// Ignore delayed lifecycle events for turns already represented by an /// authoritative transcript, while allowing one active turn to continue. pub(crate) fn reconcile_transcript_turn_events(&mut self, active_turn_id: Option<&str>) { @@ -874,7 +971,7 @@ impl ChatState { self.is_processing = false; self.permission_prompt = None; self.permission_queue.clear(); - self.question_prompt = None; + self.clear_question_prompts(); } // ============ Event Handlers ============ @@ -1074,10 +1171,18 @@ impl ChatState { } ToolEventData::Started { - identity, - params, - timeout_seconds: _, + identity, params, .. + } + | ToolEventData::UserInputRequested { + identity, params, .. } => { + let registration_sequence = match tool_event { + ToolEventData::UserInputRequested { + registration_sequence, + .. + } => Some(*registration_sequence), + _ => None, + }; let (tool_name, effective_params) = effective_tool_invocation(&identity.tool_name, params); debug_assert_eq!(identity.effective_name(), tool_name); @@ -1085,7 +1190,7 @@ impl ChatState { let params_for_create = effective_params.clone(); let tool_name_for_update = tool_name.to_string(); let tool_name_for_create = tool_name.to_string(); - self.insert_or_update_tool( + let inserted = self.insert_or_update_tool( &identity.tool_id, |tool| { tool.status = ToolDisplayStatus::Running; @@ -1104,14 +1209,26 @@ impl ChatState { subagent_progress: None, }, ); - self.metadata.tool_calls += 1; - - // Auto-create question prompt for AskUserQuestion tool - if tool_name == "AskUserQuestion" { - if let Some(prompt) = - QuestionPrompt::from_params(identity.tool_id.clone(), effective_params) - { - self.question_prompt = Some(prompt); + if inserted { + self.metadata.tool_calls += 1; + } + + // A Started event precedes channel registration. Only expose + // an answerable prompt after the Runtime owner confirms that + // the pending input channel exists. + if let Some(registration_sequence) = registration_sequence { + if tool_name == "AskUserQuestion" { + if let Some(turn_id) = self.current_turn_id.clone() { + if let Some(prompt) = QuestionPrompt::from_params( + self.core_session_id.clone(), + turn_id, + identity.tool_id.clone(), + registration_sequence, + effective_params, + ) { + self.enqueue_question_prompt(prompt); + } + } } } @@ -1127,6 +1244,21 @@ impl ChatState { self.rebuild_streaming_message(); } + ToolEventData::UserInputResolved { + identity, + registration_sequence, + } => { + let session_id = self.core_session_id.clone(); + if let Some(turn_id) = self.current_turn_id.clone() { + self.resolve_question_prompt_registration( + &session_id, + &turn_id, + &identity.tool_id, + *registration_sequence, + ); + } + } + ToolEventData::Streaming { identity, chunks_received, @@ -1195,10 +1327,6 @@ impl ChatState { tool.metadata = Some(metadata); tool.duration_ms = Some(dur); }); - // Clear question prompt if this tool completed - if self.question_prompt.as_ref().map(|p| &p.tool_id) == Some(&identity.tool_id) { - self.question_prompt = None; - } self.rebuild_streaming_message(); } @@ -1211,10 +1339,6 @@ impl ChatState { tool.status = ToolDisplayStatus::Failed; tool.result = Some(err); }); - // Clear question prompt if this tool failed - if self.question_prompt.as_ref().map(|p| &p.tool_id) == Some(&identity.tool_id) { - self.question_prompt = None; - } self.rebuild_streaming_message(); } @@ -1227,10 +1351,6 @@ impl ChatState { tool.status = ToolDisplayStatus::Cancelled; tool.result = Some(rsn); }); - // Clear question prompt if this tool was cancelled - if self.question_prompt.as_ref().map(|p| &p.tool_id) == Some(&identity.tool_id) { - self.question_prompt = None; - } self.rebuild_streaming_message(); } @@ -1339,7 +1459,7 @@ impl ChatState { self.current_flow_items.clear(); self.tool_index.clear(); self.is_processing = false; - self.question_prompt = None; + self.clear_question_prompts(); } /// Handle dialog turn failure @@ -1360,7 +1480,7 @@ impl ChatState { self.current_flow_items.clear(); self.tool_index.clear(); self.is_processing = false; - self.question_prompt = None; + self.clear_question_prompts(); } /// Handle dialog turn cancellation @@ -1380,7 +1500,7 @@ impl ChatState { self.current_flow_items.clear(); self.tool_index.clear(); self.is_processing = false; - self.question_prompt = None; + self.clear_question_prompts(); } pub(crate) fn should_apply_turn_cancelled(&mut self, turn_id: &str) -> bool { @@ -1458,12 +1578,13 @@ impl ChatState { tool_id: &str, update_fn: impl FnOnce(&mut ToolDisplayState), create_fn: impl FnOnce() -> ToolDisplayState, - ) { + ) -> bool { if let Some(&idx) = self.tool_index.get(tool_id) { // Tool already exists — update in-place if let Some(FlowItem::Tool { tool_state }) = self.current_flow_items.get_mut(idx) { update_fn(tool_state); } + false } else { // New tool — append to flow items in chronological order let new_state = create_fn(); @@ -1472,6 +1593,7 @@ impl ChatState { tool_state: new_state, }); self.tool_index.insert(tool_id.to_string(), idx); + true } } @@ -1946,6 +2068,103 @@ mod tests { assert_create_plan_item(&state.current_flow_items[0]); } + #[test] + fn ask_user_question_becomes_answerable_only_after_runtime_registration() { + let mut state = ChatState::new( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + None, + ); + state.handle_turn_started("turn-1", "Ask before continuing"); + let identity = ToolEventIdentity::direct("tool-1", "AskUserQuestion"); + let params = json!({ + "questions": [{ + "question": "Continue?", + "header": "Decision", + "options": [ + { "label": "Yes", "description": "Continue" }, + { "label": "No", "description": "Stop" } + ], + "multiSelect": false + }] + }); + + state.handle_tool_event(&ToolEventData::Started { + identity: identity.clone(), + params: params.clone(), + timeout_seconds: None, + }); + assert_eq!(state.pending_question_count(), 0); + + state.handle_tool_event(&ToolEventData::UserInputRequested { + identity: identity.clone(), + registration_sequence: 1, + params: params.clone(), + }); + assert_eq!(state.pending_question_count(), 1); + assert_eq!(state.metadata.tool_calls, 1); + + state.handle_tool_event(&ToolEventData::UserInputRequested { + identity, + registration_sequence: 1, + params, + }); + assert_eq!(state.pending_question_count(), 1); + assert_eq!(state.metadata.tool_calls, 1); + } + + #[test] + fn reused_tool_id_prompts_are_settled_by_exact_registration() { + let mut state = ChatState::new( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + None, + ); + state.handle_turn_started("turn-1", "Ask before continuing"); + let identity = ToolEventIdentity::direct("tool-1", "AskUserQuestion"); + let params = json!({ + "questions": [{ + "question": "Continue?", + "header": "Decision", + "options": [ + { "label": "Yes", "description": "Continue" }, + { "label": "No", "description": "Stop" } + ] + }] + }); + + for registration_sequence in [1, 2] { + state.handle_tool_event(&ToolEventData::UserInputRequested { + identity: identity.clone(), + registration_sequence, + params: params.clone(), + }); + } + assert_eq!(state.pending_question_count(), 2); + + state.handle_tool_event(&ToolEventData::UserInputResolved { + identity: identity.clone(), + registration_sequence: 1, + }); + assert_eq!(state.pending_question_count(), 1); + assert_eq!( + state + .question_prompt + .as_ref() + .expect("new registration remains") + .registration_sequence, + 2 + ); + + state.handle_tool_event(&ToolEventData::UserInputResolved { + identity, + registration_sequence: 1, + }); + assert_eq!(state.pending_question_count(), 1); + } + #[test] fn user_steering_is_deduplicated_and_preserves_stream_order() { let mut state = ChatState::new( diff --git a/src/apps/cli/src/daemon/runner.rs b/src/apps/cli/src/daemon/runner.rs index 7f406dfe90..453f539f73 100644 --- a/src/apps/cli/src/daemon/runner.rs +++ b/src/apps/cli/src/daemon/runner.rs @@ -75,7 +75,6 @@ pub(crate) async fn run_daemon() -> Result<()> { runtime.account_routing().stop_device_routing().await; pid::remove_pid_file(); - crate::shutdown_mcp_servers().await; tracing::info!("bitfun daemon stopped"); Ok(()) } diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs index a5f115ddec..17b1215f77 100644 --- a/src/apps/cli/src/dispatch/runner.rs +++ b/src/apps/cli/src/dispatch/runner.rs @@ -127,27 +127,14 @@ pub(crate) fn terminate_worker(pid: u32, job_id: &str) -> Result { if wait_for_process_group_exit(pid) { return Ok(true); } - - // Escalation requires a fresh, exact leader identity. The group may - // have emptied and its numeric PGID may have been reused during the - // TERM grace period, so an absent leader can no longer authenticate - // any remaining group even though it was verified before TERM. - if !process_alive(pid as u32) { - bail!( - "dispatch worker leader pid {pid} exited after SIGTERM; refusing to signal \ - unverified process group {pid} because its PGID may have been reused" - ); - } - if !process_matches_job(pid as u32, job_id) { - bail!("dispatch worker pid {pid} changed identity before SIGKILL"); - } - if !signal_process_group(pid, libc::SIGKILL)? { - return Ok(true); - } - if !wait_for_process_group_exit(pid) { - bail!("dispatch worker process group {pid} remained alive after SIGKILL"); - } - Ok(true) + escalate_after_term( + pid, + job_id, + process_alive, + process_matches_job, + |pid| signal_process_group(pid, libc::SIGKILL), + wait_for_process_group_exit, + ) } #[cfg(not(unix))] { @@ -156,6 +143,37 @@ pub(crate) fn terminate_worker(pid: u32, job_id: &str) -> Result { } } +#[cfg(unix)] +fn escalate_after_term( + pid: i32, + job_id: &str, + leader_is_alive: impl FnOnce(u32) -> bool, + leader_matches_job: impl FnOnce(u32, &str) -> bool, + send_sigkill: impl FnOnce(i32) -> Result, + wait_for_exit: impl FnOnce(i32) -> bool, +) -> Result { + // Escalation requires a fresh, exact leader identity. The group may have + // emptied and its numeric PGID may have been reused during the TERM grace + // period, so an absent leader can no longer authenticate any remaining + // group even though it was verified before TERM. + if !leader_is_alive(pid as u32) { + bail!( + "dispatch worker leader pid {pid} exited after SIGTERM; refusing to signal \ + unverified process group {pid} because its PGID may have been reused" + ); + } + if !leader_matches_job(pid as u32, job_id) { + bail!("dispatch worker pid {pid} changed identity before SIGKILL"); + } + if !send_sigkill(pid)? { + return Ok(true); + } + if !wait_for_exit(pid) { + bail!("dispatch worker process group {pid} remained alive after SIGKILL"); + } + Ok(true) +} + fn configure_detached_process(command: &mut Command) { #[cfg(unix)] { @@ -430,63 +448,22 @@ mod tests { #[cfg(unix)] #[test] fn cancellation_does_not_escalate_after_term_exits_the_verified_leader() { - struct ProcessGroupGuard(i32); - impl Drop for ProcessGroupGuard { - fn drop(&mut self) { - // SAFETY: this test created the isolated process group. - unsafe { - libc::kill(-self.0, libc::SIGKILL); - } - } - } - let job_id = "leader-exits-after-term"; - let ready_dir = tempfile::tempdir().expect("ready directory"); - let ready_path = ready_dir.path().join("term-resistant-child-ready"); - let mut command = Command::new("/bin/sh"); - command - .arg("-c") - .arg( - "trap 'exit 0' TERM; trap '' HUP; \ - sh -c 'trap \"\" TERM HUP; printf ready > \"$BITFUN_DISPATCH_TERM_TEST_READY\"; \ - while :; do sleep 30; done' & \ - while :; do sleep 30; done", - ) - // These trailing arguments make the real process identity match - // the hidden worker contract without launching BitFun Runtime. - .args(["dispatch", "__run", "--job", job_id]) - .env("BITFUN_DISPATCH_TERM_TEST_READY", &ready_path) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - configure_detached_process(&mut command); - let mut leader = command.spawn().expect("spawn process-group leader"); - let process_group = i32::try_from(leader.id()).expect("safe pid"); - let _guard = ProcessGroupGuard(process_group); - assert!(worker_process_alive(process_group as u32, job_id)); - for _ in 0..100 { - if ready_path.is_file() { - break; - } - std::thread::sleep(Duration::from_millis(10)); - } - assert!( - ready_path.is_file(), - "TERM-resistant child must be ready before cancellation" - ); - let reaper = std::thread::spawn(move || leader.wait()); - - let error = terminate_worker(process_group as u32, job_id) - .expect_err("SIGKILL must not follow a vanished leader"); + let sigkill_sent = std::cell::Cell::new(false); + let error = escalate_after_term( + 42, + job_id, + |_| false, + |_, _| panic!("an absent leader must not be matched"), + |_| { + sigkill_sent.set(true); + Ok(true) + }, + |_| panic!("an absent leader must not be awaited after SIGKILL"), + ) + .expect_err("SIGKILL must not follow a vanished leader"); assert!(error.to_string().contains("exited after SIGTERM")); assert!(error.to_string().contains("refusing to signal")); - assert!( - process_group_alive(process_group), - "the TERM-resistant child proves SIGKILL was not sent" - ); - reaper - .join() - .expect("join leader reaper") - .expect("reap process-group leader"); + assert!(!sigkill_sent.get(), "SIGKILL must not be sent"); } } diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index c37cbf41a8..02beb71b82 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -12,7 +12,7 @@ use bitfun_agent_runtime::sdk::{ use bitfun_events::{project_agentic_frontend_event, AgenticEvent}; use bitfun_runtime_ports::{AgentSubmissionSource, DialogSubmissionPolicy, SessionExecutionTarget}; -use crate::{shutdown_mcp_servers, BootstrapProfile}; +use crate::BootstrapProfile; use super::permissions::{self, REJECT_AND_REPORT_REASON}; use super::protocol::{DispatchApprovalPolicy, DispatchEvent, DispatchJobState, DispatchTurnKind}; @@ -42,7 +42,6 @@ pub(crate) async fn run(job_id: String) -> Result<()> { store.clear_pending_permissions(&job_id); store.clear_preparing(&job_id); store.remove_pid_if_matches(&job_id, worker_pid); - shutdown_mcp_servers().await; result } diff --git a/src/apps/cli/src/embedded_app_server.rs b/src/apps/cli/src/embedded_app_server.rs deleted file mode 100644 index 062d463f8f..0000000000 --- a/src/apps/cli/src/embedded_app_server.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Private in-process App Server assembly for the Embedded interactive TUI. - -use std::sync::Arc; - -use crate::runtime::CliRuntimeContext; -use crate::tui_backend::{AppServerTuiBackend, TuiBackend}; -use anyhow::{Context, Result}; -use bitfun_app_server::{AppManagementService, BitfunAppRuntime, BitfunAppServer}; -use bitfun_app_server_protocol::app::{ClientInfo, HealthStatus, InitializeRequest}; -use bitfun_app_server_protocol::PROTOCOL_VERSION; - -pub(crate) struct EmbeddedAppServerHost { - backend: Arc, - shutdown_tx: Option>, - server_thread: Option>, -} - -impl EmbeddedAppServerHost { - pub(crate) async fn start(runtime: &CliRuntimeContext) -> Result { - let (server_transport, client_transport) = - bitfun_app_server_protocol::transport::in_memory_channel_pair(); - let app_runtime = BitfunAppRuntime::new( - runtime.agent_runtime().clone(), - runtime.agent_event_source(), - ) - .with_context_reload(Arc::new(runtime.compatibility().clone())); - let management = Arc::new( - AppManagementService::load_for_local_host(Some(runtime.account_runtime().clone())) - .await?, - ); - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let server_thread = std::thread::Builder::new() - .name("bitfun-embedded-app-server".to_string()) - .stack_size(16 * 1024 * 1024) - .spawn(move || { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("failed to build Embedded App Server runtime"); - let local = tokio::task::LocalSet::new(); - runtime.block_on(local.run_until(async move { - tokio::select! { - result = BitfunAppServer::new(app_runtime) - .with_management(management) - .serve(server_transport) => { - if let Err(error) = result { - tracing::warn!("Embedded App Server stopped with an error: {error}"); - } - } - _ = shutdown_rx => {} - } - })); - }) - .context("Failed to start the Embedded App Server thread")?; - - let client = match bitfun_app_server_client::connect(client_transport).await { - Ok(client) => client, - Err(error) => { - let _ = shutdown_tx.send(()); - let _ = server_thread.join(); - return Err(error).context("Failed to connect the Embedded TUI App Server"); - } - }; - let backend: Arc = Arc::new(AppServerTuiBackend::new(client)); - let initialized = backend - .initialize(InitializeRequest { - protocol_version: PROTOCOL_VERSION, - client: ClientInfo { - name: "bitfun-tui".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - }) - .await - .context("Failed to initialize the Embedded TUI App Server")?; - if initialized.protocol_version != PROTOCOL_VERSION { - let _ = shutdown_tx.send(()); - let _ = server_thread.join(); - anyhow::bail!( - "Embedded App Server negotiated protocol {}, expected {}", - initialized.protocol_version, - PROTOCOL_VERSION - ); - } - let health = backend - .health() - .await - .context("Embedded TUI App Server health request failed")?; - if health.status != HealthStatus::Ready { - let _ = shutdown_tx.send(()); - let _ = server_thread.join(); - anyhow::bail!("Embedded TUI App Server is not ready"); - } - - Ok(Self { - backend, - shutdown_tx: Some(shutdown_tx), - server_thread: Some(server_thread), - }) - } - - pub(crate) fn backend(&self) -> Arc { - self.backend.clone() - } -} - -impl Drop for EmbeddedAppServerHost { - fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - if let Some(server_thread) = self.server_thread.take() { - let _ = server_thread.join(); - } - } -} diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 2cfc3d4f36..a235cdcc2d 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -18,7 +18,6 @@ mod config; mod daemon; mod diagnostics; mod dispatch; -mod embedded_app_server; mod hook_import; mod logging; mod management; @@ -34,17 +33,22 @@ mod root_handlers; mod runtime; mod self_update; mod shared_runtime; -mod shared_tui_backend; mod terminal_attention; -mod tui_backend; +mod tui_management; +#[cfg(test)] +mod tui_management_tests; mod ui; use anyhow::{anyhow, Result}; use bitfun_core::service::remote_connect::DeviceIdentity; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; -use std::sync::atomic::{AtomicU8, Ordering}; -use std::sync::{Arc, OnceLock}; +use futures_util::FutureExt; +use std::future::Future; +use std::panic::AssertUnwindSafe; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use agent::runtime_client::CliAgentRuntimeClient; use agent::tui_client::TuiAgentClient; use config::CliConfig; use hook_import::HookAction; @@ -59,6 +63,47 @@ static MCP_SERVICE: OnceLock = OnceLock::new(); +static MCP_LIFECYCLE: OnceLock = OnceLock::new(); + +#[derive(Default)] +struct CliMcpLifecycle { + initialization: StdMutex>>, + shutting_down: AtomicBool, +} + +impl CliMcpLifecycle { + fn track_initialization(&self, task: tokio::task::JoinHandle<()>) { + if self.shutting_down.load(Ordering::Acquire) { + task.abort(); + return; + } + if let Some(previous) = self + .initialization + .lock() + .expect("CLI MCP lifecycle lock poisoned") + .replace(task) + { + previous.abort(); + } + } + + async fn stop_initialization(&self) { + self.shutting_down.store(true, Ordering::Release); + let task = self + .initialization + .lock() + .expect("CLI MCP lifecycle lock poisoned") + .take(); + if let Some(task) = task { + task.abort(); + let _ = task.await; + } + } +} + +fn cli_mcp_lifecycle() -> &'static CliMcpLifecycle { + MCP_LIFECYCLE.get_or_init(CliMcpLifecycle::default) +} /// Get the MCP init status atomic fn get_mcp_init_status() -> &'static AtomicU8 { @@ -111,11 +156,10 @@ fn ensure_cli_mcp_service( bitfun_core::service::mcp::set_global_mcp_service(service.clone()); get_mcp_init_status().store(1, Ordering::Relaxed); - // Shared TUI keeps the pre-migration CLI-local MCP compatibility path. It - // is intentionally separate from the MCP manager inside Shared Runtime; - // this process must not be mistaken for that Runtime's owner. + // This runs only in the process that owns the Runtime: the Embedded CLI or + // the Shared Runtime Host. Shared TUI controller processes must not call it. let initializing = service.clone(); - tokio::spawn(async move { + let initialization = tokio::spawn(async move { match initializing.server_manager().initialize_all().await { Ok(_) => { tracing::info!("MCP servers initialized successfully"); @@ -127,6 +171,7 @@ fn ensure_cli_mcp_service( } } }); + cli_mcp_lifecycle().track_initialization(initialization); Some(service) } @@ -889,6 +934,7 @@ async fn initialize_core_services_for_deployment( /// Shutdown MCP servers gracefully async fn shutdown_mcp_servers() { + cli_mcp_lifecycle().stop_initialization().await; if let Some(mcp_service) = get_mcp_service() { if let Err(e) = mcp_service.server_manager().shutdown().await { tracing::warn!("Failed to shutdown MCP servers: {}", e); @@ -898,6 +944,30 @@ async fn shutdown_mcp_servers() { } } +async fn run_with_cleanup(run: Run, cleanup: Cleanup) -> Output +where + Run: Future, + Cleanup: Future, +{ + let outcome = AssertUnwindSafe(run).catch_unwind().await; + cleanup.await; + match outcome { + Ok(output) => output, + Err(payload) => std::panic::resume_unwind(payload), + } +} + +async fn load_auto_approve_ask_default() -> bool { + let Ok(service) = bitfun_core::service::config::get_global_config_service().await else { + return false; + }; + service + .get_config::(None) + .await + .map(|config| config.tool_permissions.interaction.auto_approve_ask) + .unwrap_or(false) +} + // ======================== Interactive TUI Flow ======================== /// Run the full interactive TUI flow: loading screen → startup page → chat @@ -936,52 +1006,29 @@ async fn run_interactive( .await?, ) }; - let embedded_app_server = if let Some(runtime) = &runtime { - Some(embedded_app_server::EmbeddedAppServerHost::start(runtime).await?) - } else { - None - }; let agent = if let Some(runtime) = &runtime { - let backend = embedded_app_server - .as_ref() - .expect("Embedded App Server should be started with the Runtime") - .backend(); - Arc::new(TuiAgentClient::new( - backend, + let runtime_client = Arc::new(CliAgentRuntimeClient::new_direct( + runtime, Some(workspace_path.clone()), - false, - runtime.approval_policy(), - )) + )); + let management = Arc::new( + tui_management::TuiManagementOwners::load( + Some(runtime.account_runtime().clone()), + true, + ) + .await?, + ); + Arc::new(TuiAgentClient::new(runtime_client, management)) } else { let client = shared_runtime::connect_or_start(&workspace_path).await?; - let config_service = bitfun_core::service::config::get_global_config_service() - .await - .map_err(|error| anyhow!("Failed to load Shared TUI management config: {error}"))?; - ensure_cli_mcp_service(config_service); - let management = Arc::new(bitfun_app_server::AppManagementService::load().await?); - let backend: Arc = Arc::new( - shared_tui_backend::SharedTuiBackend::new(client, management), - ); - let backend_initialized = backend - .initialize(bitfun_app_server_protocol::app::InitializeRequest { - protocol_version: bitfun_app_server_protocol::PROTOCOL_VERSION, - client: bitfun_app_server_protocol::app::ClientInfo { - name: "bitfun-tui".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - }) - .await?; - if backend_initialized.protocol_version != bitfun_app_server_protocol::PROTOCOL_VERSION { - anyhow::bail!("Shared TUI Host negotiated an incompatible protocol"); - } - backend.health().await?; - Arc::new(TuiAgentClient::new( - backend, + let runtime_client = Arc::new(CliAgentRuntimeClient::new_shared( + client, Some(workspace_path.clone()), - true, - runtime::approval::CliApprovalPolicy::Ask, - )) + )); + let management = Arc::new(tui_management::TuiManagementOwners::load(None, false).await?); + Arc::new(TuiAgentClient::new(runtime_client, management)) }; + let auto_approve_ask_default = load_auto_approve_ask_default().await; // 3.5 Restore persisted account session (if any) if !shared { let runtime = runtime @@ -1036,15 +1083,13 @@ async fn run_interactive( let restore_session_id = resolve_startup_session_override(&agent, session_spec).await?; let mut chat_mode = ChatMode::new(config, effective_agent, workspace, agent) + .with_auto_approve_ask_default(auto_approve_ask_default) .with_restore_session(restore_session_id); if let Some(mid) = model_id { chat_mode = chat_mode.with_model(mid); } let chat_result = chat_mode.run(Some(terminal)); - if !shared { - shutdown_mcp_servers().await; - } let _exit_reason = chat_result?; println!("Goodbye!"); return Ok(()); @@ -1061,7 +1106,6 @@ async fn run_interactive( let startup_result = startup_page.run(&mut terminal)?; if let StartupResult::Exit = startup_result { - shutdown_mcp_servers().await; ui::restore_terminal(terminal)?; println!("Goodbye!"); return Ok(()); @@ -1085,7 +1129,8 @@ async fn run_interactive( // Use the current project workspace selected at process start. let workspace = startup_page.workspace(); let config = startup_page.config().clone(); - let mut chat_mode = ChatMode::new(config, agent_type, workspace, agent); + let mut chat_mode = ChatMode::new(config, agent_type, workspace, agent) + .with_auto_approve_ask_default(auto_approve_ask_default); if let Some(session_id) = restore_session_id { chat_mode = chat_mode.with_restore_session(session_id); } @@ -1098,7 +1143,6 @@ async fn run_interactive( let chat_result = chat_mode.run(Some(terminal)); // 6. Cleanup, including fatal event-stream exits. - shutdown_mcp_servers().await; let _exit_reason = chat_result?; println!("Goodbye!"); @@ -1566,13 +1610,15 @@ async fn run_interactive_with_session( let workspace_path = runtime.workspace_root().to_path_buf(); let workspace = Some(workspace_path.to_string_lossy().to_string()); - let embedded_app_server = embedded_app_server::EmbeddedAppServerHost::start(&runtime).await?; - let agent = Arc::new(TuiAgentClient::new( - embedded_app_server.backend(), + let runtime_client = Arc::new(CliAgentRuntimeClient::new_direct( + &runtime, Some(workspace_path), - false, - runtime.approval_policy(), )); + let management = Arc::new( + tui_management::TuiManagementOwners::load(Some(runtime.account_runtime().clone()), true) + .await?, + ); + let agent = Arc::new(TuiAgentClient::new(runtime_client, management)); let sessions = agent.list_sessions().await?; let agent_type = sessions .iter() @@ -1585,11 +1631,12 @@ async fn run_interactive_with_session( ) })?; - let mut chat_mode = - ChatMode::new(config, agent_type, workspace, agent).with_restore_session(session_id); + let auto_approve_ask_default = load_auto_approve_ask_default().await; + let mut chat_mode = ChatMode::new(config, agent_type, workspace, agent) + .with_auto_approve_ask_default(auto_approve_ask_default) + .with_restore_session(session_id); let run_result = chat_mode.run(Some(terminal)); - shutdown_mcp_servers().await; println!("Goodbye!"); run_result?; @@ -1609,7 +1656,7 @@ fn main() { .enable_all() .build() .expect("failed to build tokio runtime"); - runtime.block_on(run_cli()) + runtime.block_on(async { run_with_cleanup(run_cli(), shutdown_mcp_servers()).await }) }) .expect("failed to spawn bitfun worker thread"); @@ -1795,7 +1842,22 @@ mod external_config_command_tests { #[cfg(test)] mod bootstrap_profile_tests { - use super::{exec_requests_json_output, BootstrapProfile, SessionAction}; + use super::{ + exec_requests_json_output, run_with_cleanup, BootstrapProfile, CliMcpLifecycle, + SessionAction, + }; + use futures_util::FutureExt; + use std::panic::AssertUnwindSafe; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + struct InitializationDropSignal(Arc); + + impl Drop for InitializationDropSignal { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } #[test] fn profiles_start_only_their_requested_background_services() { @@ -1816,6 +1878,46 @@ mod bootstrap_profile_tests { } } + #[tokio::test] + async fn mcp_shutdown_stops_and_joins_initialization_before_service_cleanup() { + let lifecycle = CliMcpLifecycle::default(); + let dropped = Arc::new(AtomicBool::new(false)); + let task_dropped = dropped.clone(); + lifecycle.track_initialization(tokio::spawn(async move { + let _drop_signal = InitializationDropSignal(task_dropped); + std::future::pending::<()>().await; + })); + tokio::task::yield_now().await; + + lifecycle.stop_initialization().await; + + assert!(dropped.load(Ordering::Acquire)); + assert!(lifecycle + .initialization + .lock() + .expect("lifecycle lock") + .is_none()); + assert!(lifecycle.shutting_down.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn top_level_panic_runs_cleanup_before_resuming_unwind() { + let cleaned_up = Arc::new(AtomicBool::new(false)); + let cleanup_signal = cleaned_up.clone(); + + let outcome = AssertUnwindSafe(run_with_cleanup( + async { panic!("simulated CLI worker panic") }, + async move { + cleanup_signal.store(true, Ordering::Release); + }, + )) + .catch_unwind() + .await; + + assert!(outcome.is_err()); + assert!(cleaned_up.load(Ordering::Acquire)); + } + #[test] fn session_resume_and_continue_use_interactive_bootstrap() { let resume = SessionAction::Resume { diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index dba216345b..8a7db08583 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -14,7 +14,7 @@ use bitfun_core::plugin_source::{ use bitfun_core::product_assembly::ProductRuntimeParts; use bitfun_core::runtime_ports::PluginRuntimeAvailability; use bitfun_core::service::config::initialize_global_config; -use bitfun_core::service::session_usage::render_usage_report_markdown; +pub(crate) use bitfun_services_core::session_usage::render_usage_report_markdown; use std::io::Write; use std::path::Path; use std::time::Duration; diff --git a/src/apps/cli/src/model_selection.rs b/src/apps/cli/src/model_selection.rs index af13622232..1633608e0c 100644 --- a/src/apps/cli/src/model_selection.rs +++ b/src/apps/cli/src/model_selection.rs @@ -1,6 +1,7 @@ -use bitfun_app_server_protocol::model::{ListModelsResponse, ModelSummary}; use bitfun_core::service::config::AIConfig; +use crate::tui_management::{ModelCatalog, ModelSummary}; + fn resolve_model_selector(ai_config: &AIConfig, selector: &str) -> Option { match selector.trim() { "" | "auto" | "default" => ai_config.resolve_model_selection("primary"), @@ -29,7 +30,7 @@ pub(crate) fn resolve_session_model_display_id( } pub(crate) fn resolve_tui_model_id( - catalog: &ListModelsResponse, + catalog: &ModelCatalog, session_selector: Option<&str>, ) -> Option { let selector = session_selector diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index bba902a043..e77be95086 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -20,15 +20,12 @@ use std::sync::{ use std::time::{Duration, Instant}; use tokio::sync::broadcast::error::TryRecvError; -use bitfun_app_server_protocol::model::{AddModelRequest, UpdateModelRequest}; -use bitfun_app_server_protocol::skill::SkillSummary; -use bitfun_app_server_protocol::subagent::SubagentSummary; -use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; +use crate::tui_management::{AddModelRequest, SkillSummary, SubagentSummary, UpdateModelRequest}; +use bitfun_events::{AgenticEvent, AgenticEventEnvelope, ToolEventData, ToolEventIdentity}; use bitfun_runtime_ports::{ - AgentSessionComposerUpdate, AgentSessionLineageEntry, - AgentSessionLineageInspection, AgentSessionLineageSnapshot, AgentSessionUsageRequest, - AgentTurnCancellationResult, AgentWorkspaceReferenceSearchResult, SessionTranscript, - WorkspaceDiffSnapshot, + AgentSessionComposerUpdate, AgentSessionLineageEntry, AgentSessionLineageInspection, + AgentSessionLineageSnapshot, AgentSessionUsageRequest, AgentTurnCancellationResult, + AgentWorkspaceReferenceSearchResult, SessionTranscript, WorkspaceDiffSnapshot, }; use resize::ResizeRedrawState; @@ -38,9 +35,17 @@ use crate::actions::{ ActionState, ResolvedKeymap, IMAGE_ATTACHMENTS_REQUIRE_MESSAGE, SHARED_TUI_EMBEDDED_HANDOFF, SHARED_TUI_HELP_NOTE, }; -use crate::agent::tui_client::{SessionOperationError, TuiAgentClient, TuiAgentMode}; +use crate::agent::tui_client::{ + CliSessionRestoreSnapshot, SessionMigrationNotice, SessionOperationError, TuiAgentClient, + TuiAgentMode, +}; use crate::chat_state::{ChatState, ModelTokenUsageSnapshot}; use crate::config::CliConfig; +use crate::management::render_usage_report_markdown; +use crate::tui_management::{ + ExternalSourceReviewAction, NativeHookOverviewView as NativeHookOverview, + NativeHookRuleSummary as NativeHookRuleView, +}; use crate::ui::agent_selector::{AgentItem, AgentSelectorAction}; use crate::ui::chat::{session_status_text, ChatView, MouseGestureOutcome}; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; @@ -67,11 +72,6 @@ use crate::ui::theme::{ }; use crate::ui::theme_selector::ThemeItem; use crate::ui::{init_terminal, restore_terminal, TerminalGuard}; -use bitfun_app_server_protocol::external_source::ExternalSourceReviewAction; -use bitfun_app_server_protocol::hook::{ - NativeHookOverview, NativeHookRuleSummary as NativeHookRuleView, -}; -use bitfun_core::service::session_usage::render_usage_report_markdown; use bitfun_product_domains::external_hook_catalog::{ ExternalHookCatalogSnapshotV1, ExternalHookMatcherSummary, ExternalHookNativeActivation, ExternalHookProjectionStatus, @@ -122,6 +122,45 @@ fn agent_event_stream_failure(error: TryRecvError) -> Option { } } +#[derive(Debug)] +struct AgentEventBatch { + events: Vec, + failure: Option, +} + +fn drain_agent_event_batch( + receiver: &mut tokio::sync::broadcast::Receiver, + limit: usize, +) -> AgentEventBatch { + let mut events = Vec::with_capacity(limit); + let mut failure = None; + for _ in 0..limit { + match receiver.try_recv() { + Ok(envelope) => events.push(envelope), + Err(error) => { + failure = agent_event_stream_failure(error); + break; + } + } + } + AgentEventBatch { events, failure } +} + +fn agent_event_connection_failure(event: &AgenticEvent) -> Option<&str> { + match event { + AgenticEvent::SystemError { + session_id: None, + error, + recoverable: false, + } => Some(error.as_str()), + _ => None, + } +} + +fn agent_event_is_relevant_to_session(event: &AgenticEvent, session_id: &str) -> bool { + event.session_id() == Some(session_id) || agent_event_connection_failure(event).is_some() +} + fn mark_active_turn_failed(chat_state: &mut ChatState, error: &str) -> bool { if chat_state.current_turn_id().is_none() { return false; @@ -131,6 +170,71 @@ fn mark_active_turn_failed(chat_state: &mut ChatState, error: &str) -> bool { true } +#[derive(Debug)] +enum SideEffectUiOutcome { + Applied(T), + Retryable(anyhow::Error), + ExitAfterUnknownOutcome(String), +} + +fn classify_side_effect_result( + operation: &str, + result: anyhow::Result, +) -> SideEffectUiOutcome { + match result { + Ok(value) => SideEffectUiOutcome::Applied(value), + Err(error) + if error + .downcast_ref::() + .is_some_and(SessionOperationError::outcome_unknown) + || crate::agent::runtime_client::side_effect_error_outcome_is_unknown(&error) => + { + SideEffectUiOutcome::ExitAfterUnknownOutcome(format!( + "The {operation} outcome is unknown: {error}. This TUI is closing; reopen it, restore the Session, and inspect authoritative state before retrying." + )) + } + Err(error) => SideEffectUiOutcome::Retryable(error), + } +} + +fn project_restored_session( + restored: CliSessionRestoreSnapshot, +) -> (ChatState, Vec) { + let active_turn_id = restored.active_turn_id; + let mut state = ChatState::from_session_transcript( + restored.summary.session_id, + restored.summary.session_name, + restored.summary.agent_type, + Some(restored.workspace_binding.workspace_path.clone()), + &restored.transcript, + ); + state.current_model_id = restored.summary.model_id; + state.current_reasoning_preset = restored.summary.reasoning_preset; + state.apply_workspace_binding(restored.workspace_binding); + state.reconcile_transcript_turn_events(active_turn_id.as_deref()); + if let Some(turn_id) = active_turn_id.as_deref() { + state.resume_transcript_turn(turn_id); + let restored_session_id = state.core_session_id.clone(); + for pending in restored.pending_user_inputs.iter().filter(|pending| { + pending.session_id == restored_session_id && pending.turn_id == turn_id + }) { + state.restore_pending_user_input(pending); + } + } + (state, restored.migration_notices) +} + +fn session_usage_workspace_path( + chat_state: &ChatState, + fallback_project_workspace: Option, +) -> Option { + chat_state + .project_workspace_path() + .map(str::to_string) + .or(fallback_project_workspace) + .filter(|path| !path.trim().is_empty()) +} + #[derive(Debug, Default)] struct TranscriptProjectionOutcome { changed: bool, @@ -192,7 +296,7 @@ fn project_transcript_event( chat_state.handle_tool_event(tool_event); outcome.requested_input = !question_pending && chat_state.question_prompt.is_some(); if !interactive { - chat_state.question_prompt = None; + chat_state.clear_question_prompts(); chat_state.permission_prompt = None; } outcome.changed = true; @@ -439,18 +543,12 @@ enum PendingLocalEffect { }, } -impl crate::tui_backend::TuiEffect for PendingLocalEffect { - fn route(&self) -> crate::tui_backend::TuiEffectRoute { - crate::tui_backend::TuiEffectRoute::Local - } -} - fn terminal_event_allowed_while_local_effect_pending(event: &Event) -> bool { matches!(event, Event::Resize(_, _)) } const SESSION_OPERATION_SLOW_NOTICE: Duration = Duration::from_secs(15); -const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, including deleting an idle Session, turns, the current Session name, current Session Agent mode, and declarative context via /reload [skills|instructions]. Model, Skill, Subagent, and MCP management use this CLI process's local compatibility owner; MCP process state and tool registration are local to this CLI process and do not reconfigure an already-running Shared Runtime Host. Local extension, account-sync, usage, and other management remain Embedded."; +const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, including deleting an idle Session, turns, the current Session name, current Session Agent mode, usage reports, and declarative context via /reload [skills|instructions]. MCP management is unavailable because MCP processes belong to the Shared Runtime Host; exit Shared clients, manage MCP in Embedded mode, then restart Shared Runtime. Remote workspace Sessions fail closed instead of falling back to controller-local management. Local extension, account-sync, and other management remain Embedded."; #[derive(Default)] struct NonKeyEventOutcome { @@ -518,6 +616,9 @@ pub(crate) struct ChatMode { pending_session_operation: Option, pending_workspace_diff: Option, pending_local_effect: Option, + /// A side-effecting Runtime operation returned OutcomeUnknown. The TUI + /// exits before another input can accidentally repeat the operation. + pending_runtime_failure: Option, pending_workspace_reference_search: Option, /// One lineage read in flight. Runtime I/O never blocks the TUI /// event loop, and refresh requests cannot overlap. @@ -601,6 +702,7 @@ impl ChatMode { pending_session_operation: None, pending_workspace_diff: None, pending_local_effect: None, + pending_runtime_failure: None, pending_workspace_reference_search: None, pending_lineage_operation: None, pending_lineage_cancellation: None, @@ -634,6 +736,11 @@ impl ChatMode { } } + pub(crate) fn with_auto_approve_ask_default(mut self, enabled: bool) -> Self { + self.auto_approve_ask_default = enabled; + self + } + /// Set a session ID to restore (for "Continue Last Session") pub(crate) fn with_restore_session(mut self, session_id: String) -> Self { self.restore_session_id = Some(session_id); diff --git a/src/apps/cli/src/modes/chat/account.rs b/src/apps/cli/src/modes/chat/account.rs index 96d6866912..cf0f566dc7 100644 --- a/src/apps/cli/src/modes/chat/account.rs +++ b/src/apps/cli/src/modes/chat/account.rs @@ -30,7 +30,7 @@ impl ChatMode { fn open_account_panel( &self, chat_view: &mut ChatView, - snapshot: bitfun_app_server_protocol::account::AccountSnapshotResponse, + snapshot: crate::tui_management::AccountSnapshotResponse, ) { let Some(info) = snapshot.info else { chat_view.show_login_form(); @@ -51,8 +51,8 @@ impl ChatMode { let progress = progress.progress; let devices = if matches!( progress.status, - bitfun_app_server_protocol::account::SettingsSyncStatus::Syncing - | bitfun_app_server_protocol::account::SettingsSyncStatus::Done + crate::tui_management::SettingsSyncStatus::Syncing + | crate::tui_management::SettingsSyncStatus::Done ) { tokio::task::block_in_place(|| { tokio::runtime::Handle::current() @@ -63,8 +63,7 @@ impl ChatMode { } else { None }; - let syncing = - progress.status == bitfun_app_server_protocol::account::SettingsSyncStatus::Syncing; + let syncing = progress.status == crate::tui_management::SettingsSyncStatus::Syncing; chat_view.update_account_panel_progress(devices, progress); syncing } @@ -128,11 +127,12 @@ impl ChatMode { } } LoginFormAction::SyncUseLocal => { - let result = tokio::task::block_in_place(|| { - rt_handle.block_on(self.agent.account_finalize_login( - bitfun_app_server_protocol::account::AccountSyncChoice::Local, - )) - }); + let result = + tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.account_finalize_login( + crate::tui_management::AccountSyncChoice::Local, + )) + }); let snapshot = match result { Ok(snapshot) => snapshot, Err(error) => { @@ -149,11 +149,12 @@ impl ChatMode { .add_system_message("Sync started (use local / upload settings).".to_string()); } LoginFormAction::SyncUseCloud => { - let result = tokio::task::block_in_place(|| { - rt_handle.block_on(self.agent.account_finalize_login( - bitfun_app_server_protocol::account::AccountSyncChoice::Cloud, - )) - }); + let result = + tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.account_finalize_login( + crate::tui_management::AccountSyncChoice::Cloud, + )) + }); let snapshot = match result { Ok(snapshot) => snapshot, Err(error) => { diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index e684ddd954..3645721b80 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -768,7 +768,7 @@ impl ChatMode { let snapshot = match snapshot { Ok(response) => { self.replace_external_conflict_preferences(response.preferences.into()); - response.snapshot + response.surface.catalog } Err(error) => { chat_state.add_system_message(format!( @@ -886,12 +886,10 @@ impl ChatMode { )) }); match expanded { - Ok(bitfun_app_server_protocol::external_source::ExpandExternalCommandResponse( - PromptCommandInvocationOutcome::Ready { - content, - execution_target, - }, - )) => { + Ok(PromptCommandInvocationOutcome::Ready { + content, + execution_target, + }) => { match execution_target { PromptCommandExecutionTarget::Inline => { self.send_message_to_agent(content, chat_view, chat_state, rt_handle); @@ -918,9 +916,7 @@ impl ChatMode { } Ok(None) } - Ok(bitfun_app_server_protocol::external_source::ExpandExternalCommandResponse( - PromptCommandInvocationOutcome::ReviewRequired { review }, - )) => { + Ok(PromptCommandInvocationOutcome::ReviewRequired { review }) => { chat_view.show_prompt_command_shell_review(review.clone()); self.pending_prompt_command_shell_invocation = Some(PendingPromptCommandShellInvocation { invocation, review }); @@ -1360,7 +1356,7 @@ impl ChatMode { } fn start_session_compaction( - &self, + &mut self, chat_view: &mut ChatView, chat_state: &ChatState, rt_handle: &tokio::runtime::Handle, @@ -1371,8 +1367,15 @@ impl ChatMode { let result = tokio::task::block_in_place(|| { rt_handle.block_on(async move { agent.start_session_compaction(&session_id).await }) }); - if let Err(error) = result { - chat_view.set_status(Some(format!("Could not compact context: {error}"))); + match classify_side_effect_result("manual compaction", result) { + SideEffectUiOutcome::Applied(_) => {} + SideEffectUiOutcome::Retryable(error) => { + chat_view.set_status(Some(format!("Could not compact context: {error}"))); + } + SideEffectUiOutcome::ExitAfterUnknownOutcome(message) => { + chat_view.set_status(Some(message.clone())); + self.pending_runtime_failure = Some(message); + } } } @@ -1411,15 +1414,23 @@ impl ChatMode { let result = tokio::task::block_in_place(|| { rt_handle.block_on(async move { agent.revert_current_session(undo).await }) }); - let reverted = match result { - Ok(reverted) => reverted, - Err(error) => { + let reverted = match classify_side_effect_result( + &format!("{} session", operation.to_ascii_lowercase()), + result, + ) { + SideEffectUiOutcome::Applied(reverted) => reverted, + SideEffectUiOutcome::Retryable(error) => { chat_view.set_status(Some(format!( "Could not {} session: {error}", operation.to_ascii_lowercase() ))); return; } + SideEffectUiOutcome::ExitAfterUnknownOutcome(message) => { + chat_view.set_status(Some(message.clone())); + self.pending_runtime_failure = Some(message); + return; + } }; chat_state.replace_from_authoritative_transcript( @@ -1638,8 +1649,8 @@ impl ChatMode { rt_handle .block_on(agent.steer_current_turn(draft.text.clone(), Some(draft.text.clone()))) }); - match result { - Ok(steering_id) => { + match classify_side_effect_result("steering submission", result) { + SideEffectUiOutcome::Applied(steering_id) => { tracing::info!( "Steering submitted: turn_id={:?}, steering_id={}", chat_state.current_turn_id(), @@ -1651,11 +1662,16 @@ impl ChatMode { let display_name = agent_display_name(&self.agent_type); chat_view.set_status(Some(format!("{} is thinking...", display_name))); } - Err(error) => { + SideEffectUiOutcome::Retryable(error) => { tracing::error!("Failed to steer active turn: {error}"); chat_view.set_status(Some(format!("Error: {error}"))); chat_view.set_draft(draft); } + SideEffectUiOutcome::ExitAfterUnknownOutcome(message) => { + tracing::error!("Steering submission outcome is unknown: {message}"); + chat_view.set_status(Some(message.clone())); + self.pending_runtime_failure = Some(message); + } } } @@ -1677,19 +1693,25 @@ impl ChatMode { chat_view.set_status(Some("Running Shell command...".to_string())); let agent = self.agent.clone(); let agent_type = self.agent_type.clone(); - match tokio::task::block_in_place(|| { + let result = tokio::task::block_in_place(|| { rt_handle.block_on(agent.run_user_shell_command(draft.text.clone(), &agent_type)) - }) { - Ok(turn_id) => { + }); + match classify_side_effect_result("Shell command", result) { + SideEffectUiOutcome::Applied(turn_id) => { tracing::info!("Started Shell turn: {}", turn_id); chat_view.remember_submitted_shell_command(&chat_state.core_session_id, &draft); chat_view.exit_shell_mode(); } - Err(error) => { + SideEffectUiOutcome::Retryable(error) => { tracing::error!("Failed to start Shell command: {error}"); chat_view.set_status(Some(format!("Error: {error}"))); chat_view.set_draft(draft); } + SideEffectUiOutcome::ExitAfterUnknownOutcome(message) => { + tracing::error!("Shell command outcome is unknown: {message}"); + chat_view.set_status(Some(message.clone())); + self.pending_runtime_failure = Some(message); + } } } diff --git a/src/apps/cli/src/modes/chat/external_sources.rs b/src/apps/cli/src/modes/chat/external_sources.rs index b98017f2f1..00186ea6c9 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -231,12 +231,8 @@ impl } } -impl From - for ExternalSourceConflictPreferences -{ - fn from( - preferences: bitfun_app_server_protocol::external_source::ExternalSourceConflictPreferences, - ) -> Self { +impl From for ExternalSourceConflictPreferences { + fn from(preferences: crate::tui_management::ExternalSourcePreferences) -> Self { Self { choices: preferences.choices, lineage_current_keys: preferences.lineage_current_keys, @@ -435,8 +431,8 @@ impl ChatMode { if matches!(&task_action, ExternalControlUiAction::Show) { let response = agent.external_source_snapshot(false).await?; return Ok(( - response.control, - Some(response.snapshot), + response.surface.control, + Some(response.surface.catalog), Some(response.preferences.into()), )); } @@ -467,8 +463,8 @@ impl ChatMode { .await?; Ok(( response.surface.control, - Some(response.snapshot.snapshot), - Some(response.snapshot.preferences.into()), + Some(response.surface.catalog), + Some(response.preferences.into()), )) } .await; @@ -636,7 +632,7 @@ impl ChatMode { } ExternalToolReviewAction::Show => unreachable!(), } - .map(|response| response.snapshot); + .map(|response| response.surface.catalog); let _ = sender.send(ExternalToolMutationResult { action: task_action, result, @@ -860,7 +856,7 @@ impl ChatMode { } ExternalAgentReviewAction::Show => unreachable!(), } - .map(|response| response.snapshot); + .map(|response| response.surface.catalog); let _ = sender.send(ExternalAgentMutationResult { action: task_action, result, diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index 0d40701b20..e704efd801 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -81,15 +81,19 @@ impl ChatMode { let result = tokio::task::block_in_place(|| { rt_handle.block_on(agent.respond_permission(&request_id, reply)) }); - match result { - Ok(()) => { + match classify_side_effect_result("permission response", result) { + SideEffectUiOutcome::Applied(()) => { chat_state.resolve_permission_request(&request_id); chat_view.set_status(Some("Permission response sent".to_string())); } - Err(error) => { + SideEffectUiOutcome::Retryable(error) => { tracing::error!("Failed to respond to permission request: {error}"); chat_view.set_status(Some(format!("Error: {error}"))); } + SideEffectUiOutcome::ExitAfterUnknownOutcome(message) => { + chat_view.set_status(Some(message.clone())); + self.pending_runtime_failure = Some(message); + } } } PermissionAction::None => {} @@ -105,22 +109,33 @@ impl ChatMode { let tool_id = prompt.tool_id.clone(); let agent = self.agent.clone(); tracing::info!("User submitted answers for tool: {}", tool_id); - match tokio::task::block_in_place(|| { - rt_handle.block_on(agent.submit_user_answers(&tool_id, answers)) - }) { - Ok(()) => { - chat_state.question_prompt = None; + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(agent.submit_user_answers( + prompt.session_id.clone(), + prompt.turn_id.clone(), + tool_id.clone(), + prompt.registration_sequence, + answers, + )) + }); + match classify_side_effect_result("user-input response", result) { + SideEffectUiOutcome::Applied(()) => { + chat_state.resolve_question_prompt(&tool_id); chat_view.set_status(Some("Answers submitted".to_string())); } - Err(error) => { + SideEffectUiOutcome::Retryable(error) => { tracing::error!("Failed to submit answers: {error}"); chat_view.set_status(Some(format!("Error: {error}"))); } + SideEffectUiOutcome::ExitAfterUnknownOutcome(message) => { + chat_view.set_status(Some(message.clone())); + self.pending_runtime_failure = Some(message); + } } } QuestionAction::Reject => { let tool_id = prompt.tool_id.clone(); - chat_state.question_prompt = None; + chat_state.resolve_question_prompt(&tool_id); tracing::info!("User dismissed question prompt: {}", tool_id); chat_view.set_status(Some("Question dismissed".to_string())); } @@ -684,22 +699,40 @@ impl ChatMode { if let Some(pending) = this.pending_session_operation.as_mut() { pending.exit_warning_shown = false; } - match this.create_new_session(session_id, chat_state, chat_view, rt_handle) { - Ok(()) => tracing::info!("Created new session: {}", session_id), - Err(e) => { + match classify_side_effect_result( + "Session creation", + this.create_new_session(session_id, chat_state, chat_view, rt_handle), + ) { + SideEffectUiOutcome::Applied(()) => { + tracing::info!("Created new session: {}", session_id) + } + SideEffectUiOutcome::Retryable(e) => { chat_state .add_system_message(format!("Failed to create new session: {}", e)); tracing::error!("Failed to create new session: {}", e); } + SideEffectUiOutcome::ExitAfterUnknownOutcome(message) => { + chat_state.add_system_message(message.clone()); + this.pending_runtime_failure = Some(message); + } } } ChatExitReason::ForkSession(target) => { - match this.fork_session(target, session_id, chat_state, chat_view, rt_handle) { - Ok(()) => tracing::info!("Forked current session: {}", session_id), - Err(error) => { + match classify_side_effect_result( + "Session fork", + this.fork_session(target, session_id, chat_state, chat_view, rt_handle), + ) { + SideEffectUiOutcome::Applied(()) => { + tracing::info!("Forked current session: {}", session_id) + } + SideEffectUiOutcome::Retryable(error) => { chat_view.set_status(Some(format!("Failed to fork session: {error}"))); tracing::error!("Failed to fork session: {error}"); } + SideEffectUiOutcome::ExitAfterUnknownOutcome(message) => { + chat_state.add_system_message(message.clone()); + this.pending_runtime_failure = Some(message); + } } } ChatExitReason::Quit => { diff --git a/src/apps/cli/src/modes/chat/mcp.rs b/src/apps/cli/src/modes/chat/mcp.rs index aafb1a539e..3a71ee7573 100644 --- a/src/apps/cli/src/modes/chat/mcp.rs +++ b/src/apps/cli/src/modes/chat/mcp.rs @@ -1,4 +1,4 @@ -use bitfun_app_server_protocol::mcp::{ +use crate::tui_management::{ ExternalMcpDecisionRequest, McpConflictChoiceRequest, McpServerAction, McpServerMutation, McpServerSummary, McpTransport, }; @@ -60,18 +60,8 @@ fn mcp_item_from_summary(server: McpServerSummary) -> McpItem { } } -/// Completion message for the `/mcp` add flow. In Shared TUI mode the add -/// mutates the local MCP compatibility owner of this CLI process, not the -/// already-running Shared Runtime Host, so it must not be reported as -/// "started" for that Runtime. -fn mcp_add_completion_message(name: &str, shared: bool) -> String { - if shared { - format!( - "MCP server '{name}' added to the local compatibility owner; the Shared Runtime Host is not reconfigured" - ) - } else { - format!("MCP server '{name}' added and started") - } +fn mcp_add_completion_message(name: &str) -> String { + format!("MCP server '{name}' added and started") } impl ChatMode { @@ -171,7 +161,6 @@ impl ChatMode { rt_handle: &tokio::runtime::Handle, ) { let agent = Arc::clone(&self.agent); - let workspace_path = self.agent.workspace_path_string(); let action = item.action.clone(); let item_id = item.id.clone(); let item_name = item.name.clone(); @@ -185,7 +174,6 @@ impl ChatMode { expected_preference_revision, } => agent .external_mcp_decision(ExternalMcpDecisionRequest { - workspace_path, candidate_id, decision_key, approved, @@ -202,7 +190,6 @@ impl ChatMode { expected_preference_revision, } => agent .mcp_conflict_choice(McpConflictChoiceRequest { - workspace_path, conflict_key, candidate_id, approve_external, @@ -273,7 +260,7 @@ impl ChatMode { tokio::task::block_in_place(|| rt_handle.block_on(handle)), ), PendingMcpTask::Add { name, handle } => ( - Some(mcp_add_completion_message(&name, self.agent.is_shared())), + Some(mcp_add_completion_message(&name)), format!("add MCP server '{name}'"), tokio::task::block_in_place(|| rt_handle.block_on(handle)), ), @@ -493,19 +480,9 @@ mod mcp_terminal_tests { } #[test] - fn shared_add_does_not_report_runtime_started() { - // In Shared TUI mode the add mutates this CLI process's local MCP - // compatibility owner, not the already-running Shared Runtime Host, - // so the completion message must not claim the server "started" for - // that Runtime and must state the local scope. - let shared = mcp_add_completion_message("srv", true); - assert!(shared.contains("local compatibility owner")); - assert!(shared.contains("Shared Runtime Host is not reconfigured")); - assert!(!shared.contains("added and started")); - - // Embedded mode owns the runtime, so "added and started" stays accurate. + fn embedded_add_reports_runtime_started() { assert_eq!( - mcp_add_completion_message("srv", false), + mcp_add_completion_message("srv"), "MCP server 'srv' added and started" ); } diff --git a/src/apps/cli/src/modes/chat/provider_models.rs b/src/apps/cli/src/modes/chat/provider_models.rs index 1fa3d5671f..1fb99afeff 100644 --- a/src/apps/cli/src/modes/chat/provider_models.rs +++ b/src/apps/cli/src/modes/chat/provider_models.rs @@ -67,7 +67,7 @@ impl ChatMode { match outcome { Ok(response) => { - let form_data = ModelFormResult::from_projection(response.model); + let form_data = ModelFormResult::from_projection(response); chat_view.show_model_config_form_for_edit(&model_id, &form_data); } Err(error) => { diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index db85947a20..4a32e2f214 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -151,10 +151,6 @@ impl ChatMode { let Some(effect) = self.pending_local_effect.take() else { return Ok(false); }; - debug_assert_eq!( - crate::tui_backend::TuiEffect::route(&effect), - crate::tui_backend::TuiEffectRoute::Local - ); match effect { PendingLocalEffect::EditComposer { command, mut draft } => { let cwd = self.local_cwd.clone(); @@ -360,22 +356,16 @@ impl ChatMode { let mut chat_view = ChatView::new(theme, shortcut_hints); chat_view.apply_presentation_config(&self.config.ui); + // Subscribe before create/restore so events emitted in the restore + // response-to-projection window remain buffered for this TUI. + let mut event_rx = self + .agent + .subscribe_events() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let mut permission_rx = self.agent.subscribe_permission_requests().ok(); + // Create or restore core session let rt_handle = tokio::runtime::Handle::current(); - self.auto_approve_ask_default = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let Ok(service) = bitfun_core::service::config::get_global_config_service().await - else { - return false; - }; - service - .get_config::(None) - .await - .map(|config| config.tool_permissions.interaction.auto_approve_ask) - .unwrap_or(false) - }) - }); - let (mut session_id, mut chat_state, migration_notices) = if let Some(ref restore_id) = self.restore_session_id { // Restore existing session @@ -386,24 +376,14 @@ impl ChatMode { tokio::task::block_in_place(|| { rt_handle.block_on(async { // Restore session in core (loads metadata, messages, managers) - let (summary, workspace_binding, migration_notices, transcript) = - agent.restore_session_in_current_workspace(&rid).await?; - let effective_workspace = Some(workspace_binding.workspace_path.clone()); - - let mut state = ChatState::from_session_transcript( - rid.clone(), - summary.session_name, - summary.agent_type, - effective_workspace, - &transcript, - ); - state.current_model_id = summary.model_id; - state.apply_workspace_binding(workspace_binding); + let restored = agent.restore_session_in_current_workspace(&rid).await?; + let message_count = restored.transcript.messages.len(); + let (state, migration_notices) = project_restored_session(restored); tracing::info!( "Session restored: {}, {} messages loaded", rid, - transcript.messages.len() + message_count ); Ok::<_, anyhow::Error>((rid, state, migration_notices)) @@ -416,8 +396,9 @@ impl ChatMode { let (session_id, workspace_binding, session_summary) = tokio::task::block_in_place(|| { rt_handle.block_on(async { - let session_id = agent.ensure_session(&agent_type).await?; - let binding = agent.session_workspace_binding(&session_id).await?; + let (session_id, binding) = agent + .ensure_session_with_workspace_binding(&agent_type) + .await?; let summary = agent .list_sessions() .await? @@ -484,7 +465,7 @@ impl ChatMode { match initial_external_sources { Ok(response) => { self.replace_external_conflict_preferences(response.preferences.into()); - let snapshot = response.snapshot; + let snapshot = response.surface.catalog; let (available, restricted) = external_command_counts(&snapshot); let pending_conflicts = snapshot .command_conflicts @@ -538,11 +519,6 @@ impl ChatMode { } } - let mut event_rx = self - .agent - .subscribe_events() - .map_err(|error| anyhow::anyhow!(error.to_string()))?; - let mut permission_rx = self.agent.subscribe_permission_requests().ok(); if let Ok(pending) = self.agent.pending_permission_requests() { for request in pending.into_iter().filter(|request| { crate::runtime::approval::permission_request_targets_session(request, &session_id) @@ -640,6 +616,10 @@ impl ChatMode { break; } } + if let Some(message) = self.pending_runtime_failure.take() { + fatal_event_stream_error = Some(message); + break; + } if self.poll_external_tool_mutation(&mut chat_view) { needs_redraw = true; } @@ -779,7 +759,7 @@ impl ChatMode { let snapshot = match response { Ok(response) => { self.replace_external_conflict_preferences(response.preferences.into()); - response.snapshot + response.surface.catalog } Err(error) => { tracing::warn!( @@ -896,39 +876,13 @@ impl ChatMode { } // 2. Process core events (non-blocking) - let mut events = Vec::with_capacity(20); - for _ in 0..20 { - match event_rx.try_recv() { - Ok(envelope) => events.push(envelope), - Err(error) => { - let Some(mut failure) = agent_event_stream_failure(error) else { - break; - }; - - // The adapter records the turn before DialogTurnStarted reaches the UI, - // so cancellation must not depend on ChatState having seen that event. - let agent = self.agent.clone(); - if let Err(cancel_error) = tokio::task::block_in_place(|| { - rt_handle.block_on(agent.cancel_current_turn()) - }) { - failure = format!( - "{failure}; failed to cancel the active turn: {cancel_error}" - ); - } - mark_active_turn_failed(&mut chat_state, &failure); - chat_view.invalidate_lines_cache(); - chat_view.set_status(Some(format!("Error: {failure}"))); - tracing::error!("{failure}"); - fatal_event_stream_error = Some(failure); - break; - } - } - } - if fatal_event_stream_error.is_some() { - break; - } - for envelope in events { + let event_batch = drain_agent_event_batch(&mut event_rx, 20); + let mut connection_failure = None; + for envelope in event_batch.events { let event = &envelope.event; + if let Some(failure) = agent_event_connection_failure(event) { + connection_failure = Some(failure.to_string()); + } if let AgenticEvent::SubagentSessionLinked { session_id: subagent_session_id, parent_session_id, @@ -944,7 +898,7 @@ impl ChatMode { } // Check if this is a subagent event that belongs to our session - if event.session_id() != Some(&session_id) { + if !agent_event_is_relevant_to_session(event, &session_id) { if self.project_inspected_lineage_event(event) { needs_redraw = true; } @@ -1047,6 +1001,27 @@ impl ChatMode { } } } + if let Some(mut failure) = connection_failure.or(event_batch.failure) { + // Project every event received before the failure first. A terminal event may + // have already settled the turn, in which case cancellation would be wrong. + if chat_state.current_turn_id().is_some() { + // The adapter records the turn before DialogTurnStarted reaches the UI, + // so cancellation must not depend on ChatState having seen that event. + let agent = self.agent.clone(); + if let Err(cancel_error) = tokio::task::block_in_place(|| { + rt_handle.block_on(agent.cancel_current_turn()) + }) { + failure = + format!("{failure}; failed to cancel the active turn: {cancel_error}"); + } + mark_active_turn_failed(&mut chat_state, &failure); + } + chat_view.invalidate_lines_cache(); + chat_view.set_status(Some(format!("Error: {failure}"))); + tracing::error!("{failure}"); + fatal_event_stream_error = Some(failure); + break; + } if self.refresh_inspected_lineage_if_due(&mut chat_view, &rt_handle) { chat_view.invalidate_lines_cache(); needs_redraw = true; diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index d9048ee44f..9efac683e9 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -231,11 +231,10 @@ impl ChatMode { } let session_id = chat_state.core_session_id.clone(); - let workspace_path = chat_state - .workspace - .clone() - .or_else(|| self.workspace.clone()) - .or_else(|| Some(self.agent.workspace_path_string())); + let workspace_path = session_usage_workspace_path( + chat_state, + Some(self.agent.project_workspace_path_string()), + ); let agent = self.agent.clone(); /* @@ -249,7 +248,7 @@ impl ChatMode { * never persisted, never in model context. In a terminal the scrollback * is the record, so nothing here needs to replace what is being removed. */ - let report_result: Result = + let report_result: Result = tokio::task::block_in_place(|| { let session_id = session_id.clone(); let workspace_path = workspace_path.clone(); diff --git a/src/apps/cli/src/modes/chat/sessions.rs b/src/apps/cli/src/modes/chat/sessions.rs index d49e1ffff3..29897ff9c4 100644 --- a/src/apps/cli/src/modes/chat/sessions.rs +++ b/src/apps/cli/src/modes/chat/sessions.rs @@ -96,21 +96,9 @@ impl ChatMode { let (new_state, restored_agent_type, migration_notices) = tokio::task::block_in_place(|| { rt_handle.block_on(async { - let (session_summary, workspace_binding, migration_notices, transcript) = - agent.restore_session_in_current_workspace(&sid).await?; - let restored_agent_type = session_summary.agent_type.clone(); - let effective_workspace = Some(workspace_binding.workspace_path.clone()); - - let mut state = ChatState::from_session_transcript( - sid.clone(), - session_summary.session_name, - restored_agent_type.clone(), - effective_workspace, - &transcript, - ); - state.current_model_id = session_summary.model_id; - state.current_reasoning_preset = session_summary.reasoning_preset; - state.apply_workspace_binding(workspace_binding); + let restored = agent.restore_session_in_current_workspace(&sid).await?; + let restored_agent_type = restored.summary.agent_type.clone(); + let (state, migration_notices) = project_restored_session(restored); Ok::<_, anyhow::Error>((state, restored_agent_type, migration_notices)) }) @@ -158,9 +146,9 @@ impl ChatMode { let (new_session_id, workspace_binding) = tokio::task::block_in_place(|| { rt_handle.block_on(async { - let session_id = agent.create_new_session(&agent_type).await?; - let binding = agent.session_workspace_binding(&session_id).await?; - Ok::<_, anyhow::Error>((session_id, binding)) + agent + .create_new_session_with_workspace_binding(&agent_type) + .await }) })?; @@ -330,23 +318,29 @@ impl ChatMode { let agent = self.agent.clone(); let agent_type = self.agent_type.clone(); let attachments = draft.runtime_attachments(); - match tokio::task::block_in_place(|| { + let result = tokio::task::block_in_place(|| { rt_handle.block_on(agent.send_message_with_context( draft.text.clone(), draft.workspace_references.clone(), attachments, &agent_type, )) - }) { - Ok(turn_id) => { + }); + match classify_side_effect_result("prompt submission", result) { + SideEffectUiOutcome::Applied(turn_id) => { tracing::info!("Started turn: {}", turn_id); chat_view.remember_submitted_draft(&chat_state.core_session_id, &draft); } - Err(e) => { - tracing::error!("Failed to send message: {}", e); - chat_view.set_status(Some(format!("Error: {}", e))); + SideEffectUiOutcome::Retryable(error) => { + tracing::error!("Failed to send message: {}", error); + chat_view.set_status(Some(format!("Error: {}", error))); chat_view.set_draft(draft); } + SideEffectUiOutcome::ExitAfterUnknownOutcome(message) => { + tracing::error!("Prompt submission outcome is unknown: {message}"); + chat_view.set_status(Some(message.clone())); + self.pending_runtime_failure = Some(message); + } } } diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 966650120d..6c019f6268 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -3,16 +3,17 @@ mod tests { use tokio::sync::broadcast::error::TryRecvError; use super::{ - action_opens_extension_management, agent_event_stream_failure, apply_agent_mode_feedback, + action_opens_extension_management, agent_event_connection_failure, + agent_event_is_relevant_to_session, agent_event_stream_failure, apply_agent_mode_feedback, apply_model_selection_feedback, apply_session_model_migration, apply_session_rename_feedback, begin_slash_menu_selection, builtin_arguments_error, - builtin_arguments_route, builtin_command_reconfirmation, + builtin_arguments_route, builtin_command_reconfirmation, classify_side_effect_result, clear_selected_native_command_prefill, cli_native_prompt_command_descriptors, command_route, consume_selected_native_command_once, context_compression_tool_event, - extension_command_help_request, external_agent_attention, external_agent_diagnostic_lines, - external_agent_pending_notice_key, external_agent_result_is_stale, - external_agent_review_text, external_command_projections, external_control_status_text, - external_hook_help_text, external_integration_policy_lines, + drain_agent_event_batch, extension_command_help_request, external_agent_attention, + external_agent_diagnostic_lines, external_agent_pending_notice_key, + external_agent_result_is_stale, external_agent_review_text, external_command_projections, + external_control_status_text, external_hook_help_text, external_integration_policy_lines, external_operation_error_status, external_tool_mutation_result_label, external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, external_tool_run_location_label, mark_active_turn_failed, @@ -22,30 +23,33 @@ mod tests { parse_external_tool_review_action, parse_hook_management_action, parse_reload_invocation, parse_reload_target, pending_session_operation_blocks_runtime_action, previous_session_update_status, primary_model_usage_for_active_turn, - render_external_hook_catalog, render_native_hook_overview, requested_session_name, + project_restored_session, project_transcript_event, render_external_hook_catalog, + render_native_hook_overview, requested_session_name, retain_selected_native_command_for_input, selected_command_prefill, session_command_help_note, session_delete_allowed, session_delete_feedback, session_switch_targets_pending_delete, session_update_allowed, session_update_blocks_typed_submission, session_update_completion_should_exit, - shared_session_change_is_blocked, steering_unsupported_reason, - terminal_event_allowed_while_local_effect_pending, CommandRoute, ExternalAgentReviewAction, - ExternalControlUiAction, ExternalSourceConflictPreferences, ExternalToolReviewAction, - HookManagementAction, PendingSessionOperationKind, SessionUpdateApplyOutcome, - SHARED_TUI_CHAT_STATUS, + session_usage_workspace_path, shared_session_change_is_blocked, + steering_unsupported_reason, terminal_event_allowed_while_local_effect_pending, + CommandRoute, ExternalAgentReviewAction, ExternalControlUiAction, + ExternalSourceConflictPreferences, ExternalToolReviewAction, HookManagementAction, + PendingSessionOperationKind, SessionOperationError, SessionUpdateApplyOutcome, + SideEffectUiOutcome, TranscriptTerminalOutcome, SHARED_TUI_CHAT_STATUS, }; use crate::actions::{ action_conflict_behavior_version, ActionHandler, ActionState, ResolvedKeymap, }; + use crate::agent::tui_client::CliSessionRestoreSnapshot; use crate::chat_state::ChatState; use crate::config::ShortcutsConfig; + use crate::tui_management::{ + NativeHookFileSummary as NativeHookFileView, + NativeHookHandlerSummary as NativeHookHandlerView, + NativeHookOverviewView as NativeHookOverview, NativeHookRuleSummary as NativeHookRuleView, + }; use crate::ui::chat::ChatView; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; use crate::ui::theme::Theme; - use bitfun_app_server_protocol::hook::{ - NativeHookFileSummary as NativeHookFileView, - NativeHookHandlerSummary as NativeHookHandlerView, NativeHookOverview, - NativeHookRuleSummary as NativeHookRuleView, - }; use bitfun_events::{AgenticEvent, ToolEventData}; use bitfun_product_domains::external_hook_catalog::{ ExternalHookCatalogEntry, ExternalHookCatalogSnapshotV1, ExternalHookHandlerKind, @@ -63,7 +67,10 @@ mod tests { use bitfun_product_domains::external_subagents::{ ExternalSubagentActivationState, ExternalSubagentModelBindingTarget, }; - use bitfun_runtime_ports::AgentContextReloadTarget; + use bitfun_runtime_ports::{ + AgentContextReloadTarget, AgentSessionSummary, AgentSessionWorkspaceBinding, + PendingUserInput, SessionExecutionTarget, SessionTranscript, + }; use crossterm::event::Event; use std::collections::{BTreeMap, BTreeSet}; @@ -935,7 +942,6 @@ mod tests { scope: "user".to_string(), handlers: vec![NativeHookHandlerView { command_summary: "jq -r '.tool_input.command' >> ~/log".to_string(), - command_truncated: false, timeout_seconds: 600, status_message: None, }], @@ -1438,6 +1444,246 @@ mod tests { assert!(!state.is_processing); } + #[test] + fn closed_agent_stream_keeps_buffered_terminal_events_ahead_of_failure() { + let (sender, mut receiver) = tokio::sync::broadcast::channel(4); + sender + .send(bitfun_events::AgenticEventEnvelope::new( + AgenticEvent::DialogTurnCompleted { + session_id: "session".to_string(), + turn_id: "turn".to_string(), + total_rounds: 1, + total_tools: 0, + duration_ms: 1, + partial_recovery_reason: None, + success: Some(true), + finish_reason: None, + has_final_response: Some(true), + }, + bitfun_events::AgenticEventPriority::Critical, + )) + .expect("buffer terminal event"); + drop(sender); + + let batch = drain_agent_event_batch(&mut receiver, 20); + assert_eq!(batch.events.len(), 1); + assert!(batch + .failure + .as_deref() + .is_some_and(|message| message.contains("closed"))); + + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.handle_turn_started("turn", "hello"); + let projection = project_transcript_event(&mut state, &batch.events[0].event, true); + assert!(matches!( + projection.terminal, + Some(TranscriptTerminalOutcome::Completed) + )); + assert_eq!(state.current_turn_id(), None); + } + + #[test] + fn sessionless_shared_invalidation_survives_root_filter_and_closed_stream() { + let detailed_failure = + "Shared Runtime rejected an oversized event frame; reconnect in Embedded mode"; + let (sender, mut receiver) = tokio::sync::broadcast::channel(4); + sender + .send(bitfun_events::AgenticEventEnvelope::new( + AgenticEvent::SystemError { + session_id: None, + error: detailed_failure.to_string(), + recoverable: false, + }, + bitfun_events::AgenticEventPriority::Critical, + )) + .expect("buffer connection failure"); + drop(sender); + + let batch = drain_agent_event_batch(&mut receiver, 20); + let event = &batch.events[0].event; + assert!(agent_event_is_relevant_to_session(event, "session")); + let connection_failure = agent_event_connection_failure(event).map(str::to_string); + + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.handle_turn_started("turn", "hello"); + let projection = project_transcript_event(&mut state, event, true); + assert!(matches!( + projection.terminal, + Some(TranscriptTerminalOutcome::SystemError(ref error)) if error == detailed_failure + )); + assert_eq!( + connection_failure.or(batch.failure), + Some(detailed_failure.to_string()) + ); + } + + #[test] + fn session_usage_uses_the_project_storage_root_instead_of_the_execution_worktree() { + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/project".to_string()), + ); + state.apply_workspace_binding(AgentSessionWorkspaceBinding { + workspace_id: Some("workspace".to_string()), + workspace_path: "D:/managed-worktree".to_string(), + project_workspace_path: Some("D:/project".to_string()), + execution_target: Some(SessionExecutionTarget::local("D:/managed-worktree")), + remote_connection_id: None, + remote_ssh_host: None, + }); + + assert_eq!( + session_usage_workspace_path(&state, Some("D:/fallback".to_string())).as_deref(), + Some("D:/project") + ); + } + + #[test] + fn unknown_side_effects_exit_before_the_ui_can_offer_a_retry() { + let unknown = anyhow::Error::new(SessionOperationError::runtime( + bitfun_agent_runtime::sdk::RuntimeError::Port( + bitfun_agent_runtime::sdk::PortError::new( + bitfun_agent_runtime::sdk::PortErrorKind::OutcomeUnknown, + "the operation may have been accepted", + ), + ), + )); + let rejected = anyhow::anyhow!("request rejected before execution"); + + assert!(matches!( + classify_side_effect_result::<()>("Shell command", Err(unknown)), + SideEffectUiOutcome::ExitAfterUnknownOutcome(message) + if message.contains("inspect authoritative state before retrying") + )); + assert!(matches!( + classify_side_effect_result::<()>("Shell command", Err(rejected)), + SideEffectUiOutcome::Retryable(_) + )); + } + + #[test] + fn root_restore_resumes_authoritative_processing_turn() { + let restored = CliSessionRestoreSnapshot { + summary: AgentSessionSummary { + session_id: "session".to_string(), + session_name: "Restored".to_string(), + agent_type: "agentic".to_string(), + model_id: Some("model".to_string()), + reasoning_preset: Some("high".to_string()), + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, + turn_count: 1, + created_at_ms: 1, + last_active_at_ms: 2, + }, + workspace_binding: AgentSessionWorkspaceBinding { + workspace_id: Some("workspace".to_string()), + workspace_path: "D:/project".to_string(), + project_workspace_path: Some("D:/project".to_string()), + execution_target: Some(SessionExecutionTarget::local("D:/project")), + remote_connection_id: None, + remote_ssh_host: None, + }, + migration_notices: Vec::new(), + transcript: SessionTranscript { + session_id: "session".to_string(), + messages: Vec::new(), + }, + active_turn_id: Some("turn".to_string()), + pending_user_inputs: [ + ("question-tool", "child-session", "child-turn", 1), + ("question-tool-2", "child-session-2", "child-turn-2", 2), + ] + .into_iter() + .map( + |(tool_id, source_session_id, source_turn_id, registration_sequence)| { + PendingUserInput { + tool_id: tool_id.to_string(), + session_id: "session".to_string(), + turn_id: "turn".to_string(), + source_session_id: source_session_id.to_string(), + source_turn_id: source_turn_id.to_string(), + registration_sequence, + input: serde_json::json!({ + "questions": [{ + "question": "Choose a path", + "header": "Path", + "options": [{ + "label": "Safe", + "description": "Use the safe path" + }], + "multiSelect": false + }] + }), + } + }, + ) + .collect(), + }; + + let (mut state, notices) = project_restored_session(restored); + assert!(notices.is_empty()); + assert!(state.is_processing); + assert_eq!(state.current_turn_id(), Some("turn")); + assert_eq!(state.current_reasoning_preset.as_deref(), Some("high")); + assert_eq!( + state + .question_prompt + .as_ref() + .map(|prompt| prompt.tool_id.as_str()), + Some("question-tool") + ); + assert_eq!(state.pending_question_count(), 2); + assert!(state.resolve_question_prompt("question-tool")); + assert_eq!( + state + .question_prompt + .as_ref() + .map(|prompt| prompt.tool_id.as_str()), + Some("question-tool-2") + ); + + let chunk = AgenticEvent::TextChunk { + session_id: "session".to_string(), + turn_id: "turn".to_string(), + round_id: "round".to_string(), + attempt_id: None, + attempt_index: None, + text: "tail".to_string(), + }; + assert!(project_transcript_event(&mut state, &chunk, true).changed); + let completed = AgenticEvent::DialogTurnCompleted { + session_id: "session".to_string(), + turn_id: "turn".to_string(), + total_rounds: 1, + total_tools: 0, + duration_ms: 1, + partial_recovery_reason: None, + success: Some(true), + finish_reason: None, + has_final_response: Some(true), + }; + let projection = project_transcript_event(&mut state, &completed, true); + assert!(matches!( + projection.terminal, + Some(TranscriptTerminalOutcome::Completed) + )); + assert!(!state.is_processing); + assert_eq!(state.current_turn_id(), None); + } + #[test] fn primary_model_usage_projection_rejects_other_sessions_turns_and_subagents() { let mut state = ChatState::new( @@ -2217,15 +2463,14 @@ mod tests { } #[test] - fn shared_chat_status_describes_local_compatibility_management() { + fn shared_chat_status_describes_owner_scoped_management() { assert!(SHARED_TUI_CHAT_STATUS.contains("current Session Agent mode")); assert!(!SHARED_TUI_CHAT_STATUS.contains("current Session model")); assert!(SHARED_TUI_CHAT_STATUS.contains("current Session name")); assert!(SHARED_TUI_CHAT_STATUS.contains("/reload [skills|instructions]")); - assert!(SHARED_TUI_CHAT_STATUS.contains("Model, Skill, Subagent, and MCP management")); - assert!(SHARED_TUI_CHAT_STATUS.contains("local compatibility owner")); - assert!(SHARED_TUI_CHAT_STATUS - .contains("do not reconfigure an already-running Shared Runtime Host")); + assert!(SHARED_TUI_CHAT_STATUS.contains("MCP management is unavailable")); + assert!(SHARED_TUI_CHAT_STATUS.contains("restart Shared Runtime")); + assert!(SHARED_TUI_CHAT_STATUS.contains("Remote workspace Sessions fail closed")); assert!(SHARED_TUI_CHAT_STATUS.contains("other management remain Embedded")); } diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index ec4e1fa345..cc43b81329 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -821,6 +821,11 @@ mod tests { last_active_at_ms: 20_000, }, state: SessionState::Idle, + transcript: bitfun_runtime_ports::SessionTranscript { + session_id: "session_1".to_string(), + messages: Vec::new(), + }, + pending_user_inputs: Vec::new(), }); assert_eq!(value["sessionId"], "session_1"); diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 3c876b9447..18ac21af72 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -225,8 +225,6 @@ pub(crate) async fn handle_exec_command(config: CliConfig, args: ExecCommandArgs ); let run_result = exec_mode.run().await; - crate::shutdown_mcp_servers().await; - run_result } diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index a5af4e4b6c..7d4a45b64a 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -18,7 +18,7 @@ use bitfun_core::runtime_ownership::CoreRuntimeOwnership; use bitfun_events::{AgenticEvent, ToolEventData}; use bitfun_runtime_ports::{AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest}; use bitfun_services_core::runtime_ownership::RuntimeDeployment; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex}; @@ -32,6 +32,7 @@ const IDLE_TIMEOUT: Duration = Duration::from_secs(30); const SERVER_OPERATION_TIMEOUT: Duration = Duration::from_secs(120); const CLIENT_REQUEST_TIMEOUT: Duration = Duration::from_secs(125); const EVENT_BUFFER: usize = 256; +const USER_QUESTION_RESOLVED_LIMIT: usize = 2048; const SUBAGENT_ROUTE_TIMEOUT: Duration = Duration::from_secs(2); type SessionEventSenders = Mutex>>; @@ -45,12 +46,50 @@ struct SubagentRoute { type SubagentRoutes = Mutex>; +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct UserQuestionAuthorization { + session_id: String, + turn_id: String, + tool_id: String, + registration_sequence: u64, +} + +impl UserQuestionAuthorization { + fn from_pending(pending: &bitfun_agent_runtime::sdk::PendingUserInput) -> Self { + Self { + session_id: pending.session_id.clone(), + turn_id: pending.turn_id.clone(), + tool_id: pending.tool_id.clone(), + registration_sequence: pending.registration_sequence, + } + } + + fn matches_request( + &self, + request: &bitfun_agent_runtime_ipc::RuntimeUserAnswersRequest, + ) -> bool { + self.session_id == request.session_id + && self.turn_id == request.turn_id + && self.tool_id == request.tool_id + && self.registration_sequence == request.registration_sequence + } +} + +#[derive(Default)] +struct UserQuestionAuthorizationState { + ready: HashSet, + resolved: HashSet, + resolved_order: VecDeque, +} + +type UserQuestionAuthorizations = Mutex; + pub(crate) struct SharedRuntimeHandler { runtime: AgentRuntime, compatibility: CoreAgentRuntimeCompatibility, workspace: PathBuf, events: Arc, - question_sessions: Arc>>, + question_sessions: Arc, subagent_routes: Arc, event_stream_available: watch::Sender, } @@ -71,7 +110,7 @@ impl SharedRuntimeHandler { .context("subscribe Shared Runtime permission events")?; let events = Arc::new(Mutex::new(HashMap::new())); let permission_sessions = Arc::new(Mutex::new(HashMap::new())); - let question_sessions = Arc::new(Mutex::new(HashMap::new())); + let question_sessions = Arc::new(Mutex::new(UserQuestionAuthorizationState::default())); let subagent_routes = Arc::new(SubagentRoutes::new(HashMap::new())); let route_updates = Arc::new(Notify::new()); let (event_stream_available, _) = watch::channel(true); @@ -284,14 +323,6 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { }) .await .map_err(runtime_ipc_error)?; - let transcript = self - .runtime - .read_session_transcript(SessionTranscriptRequest { - session_id: restored.session.session_id.clone(), - turn_id: None, - }) - .await - .map_err(runtime_ipc_error)?; let pending_permissions = self .runtime .pending_permission_requests() @@ -308,12 +339,18 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { let workspace_binding = self .session_workspace_binding(&restored.session.session_id) .await?; + reconcile_restored_user_questions( + &restored.session.session_id, + &restored.pending_user_inputs, + &self.question_sessions, + ); Ok(RuntimeIpcOperationResult::SessionRestored { session: restored.session, state: runtime_session_state(restored.state), workspace_binding, - transcript, + transcript: restored.transcript, pending_permissions, + pending_user_inputs: restored.pending_user_inputs, }) } RuntimeIpcOperation::ForkSession { request } => { @@ -352,7 +389,8 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { remote_ssh_host: None, }) .await - .map_err(runtime_ipc_error)?; + .map_err(runtime_ipc_error) + .map_err(|error| completed_session_fork_followup_error("restore", error))?; let transcript = self .runtime .read_session_transcript(SessionTranscriptRequest { @@ -360,10 +398,14 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { turn_id: None, }) .await - .map_err(runtime_ipc_error)?; + .map_err(runtime_ipc_error) + .map_err(|error| completed_session_fork_followup_error("transcript", error))?; let workspace_binding = self .session_workspace_binding(&restored.session.session_id) - .await?; + .await + .map_err(|error| { + completed_session_fork_followup_error("workspace binding", error) + })?; Ok(RuntimeIpcOperationResult::SessionForked { session: restored.session, workspace_binding, @@ -567,30 +609,8 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { Ok(RuntimeIpcOperationResult::Unit) } RuntimeIpcOperation::SubmitUserAnswers { request } => { - let permitted = self - .question_sessions - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .get(&request.tool_id) - .is_some_and(|session_id| session_id == &request.session_id); - if !permitted { - return Err(RuntimeIpcError { - code: RuntimeIpcErrorCode::SessionMismatch, - message: "user-input request does not belong to the controlled session" - .to_string(), - }); - } - self.runtime - .submit_user_answers(AgentUserAnswersRequest { - tool_id: request.tool_id.clone(), - answers: request.answers, - }) - .await - .map_err(runtime_ipc_error)?; - self.question_sessions - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(&request.tool_id); + submit_authorized_user_answers(&self.runtime, &self.question_sessions, request) + .await?; Ok(RuntimeIpcOperationResult::Unit) } } @@ -604,6 +624,19 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { } } +fn completed_session_fork_followup_error( + followup: &str, + error: RuntimeIpcError, +) -> RuntimeIpcError { + RuntimeIpcError { + code: RuntimeIpcErrorCode::OutcomeUnknown, + message: format!( + "Session fork completed, but its {followup} could not be returned: {}. Reconnect and inspect the Session list before retrying", + error.message + ), + } +} + fn runtime_session_state(state: bitfun_agent_runtime::sdk::SessionState) -> RuntimeSessionState { use bitfun_agent_runtime::sdk::{ProcessingPhase, SessionState}; @@ -709,7 +742,7 @@ fn invalidate_event_stream( events: &SessionEventSenders, reason: RuntimeIpcStreamInvalidationReason, ) { - if available.send_replace(false) { + if *available.borrow() { for sender in events .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -717,6 +750,7 @@ fn invalidate_event_stream( { let _ = sender.send(RuntimeIpcEvent::StreamInvalidated { reason }); } + available.send_replace(false); } } @@ -965,30 +999,133 @@ fn project_user_question_route( fn index_user_question( event: &AgenticEvent, routed_session_id: &str, - questions: &Mutex>, + questions: &UserQuestionAuthorizations, ) { - let AgenticEvent::ToolEvent { tool_event, .. } = event else { + let AgenticEvent::ToolEvent { + turn_id, + tool_event, + .. + } = event + else { return; }; let mut questions = questions .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); match tool_event { - ToolEventData::Started { .. } if tool_event.effective_tool_name() == "AskUserQuestion" => { - questions.insert( - tool_event.tool_id().to_string(), - routed_session_id.to_string(), - ); + ToolEventData::UserInputRequested { + registration_sequence, + .. + } if tool_event.effective_tool_name() == "AskUserQuestion" => { + let authorization = UserQuestionAuthorization { + session_id: routed_session_id.to_string(), + turn_id: turn_id.clone(), + tool_id: tool_event.tool_id().to_string(), + registration_sequence: *registration_sequence, + }; + merge_user_question_authorization(&mut questions, authorization); } - ToolEventData::Completed { .. } - | ToolEventData::Failed { .. } - | ToolEventData::Cancelled { .. } => { - questions.remove(tool_event.tool_id()); + ToolEventData::UserInputResolved { + registration_sequence, + .. + } if tool_event.effective_tool_name() == "AskUserQuestion" => { + let authorization = UserQuestionAuthorization { + session_id: routed_session_id.to_string(), + turn_id: turn_id.clone(), + tool_id: tool_event.tool_id().to_string(), + registration_sequence: *registration_sequence, + }; + record_user_question_resolved(&mut questions, authorization); } _ => {} } } +fn reconcile_restored_user_questions( + session_id: &str, + pending_user_inputs: &[bitfun_agent_runtime::sdk::PendingUserInput], + questions: &UserQuestionAuthorizations, +) { + let mut questions = questions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for pending in pending_user_inputs { + if pending.session_id == session_id { + merge_user_question_authorization( + &mut questions, + UserQuestionAuthorization::from_pending(pending), + ); + } + } +} + +fn merge_user_question_authorization( + questions: &mut UserQuestionAuthorizationState, + candidate: UserQuestionAuthorization, +) { + if !questions.resolved.contains(&candidate) { + questions.ready.insert(candidate); + } +} + +fn record_user_question_resolved( + questions: &mut UserQuestionAuthorizationState, + authorization: UserQuestionAuthorization, +) { + questions.ready.remove(&authorization); + if questions.resolved.insert(authorization.clone()) { + questions.resolved_order.push_back(authorization); + } + while questions.resolved.len() > USER_QUESTION_RESOLVED_LIMIT { + if let Some(expired) = questions.resolved_order.pop_front() { + questions.resolved.remove(&expired); + } + } +} + +async fn submit_authorized_user_answers( + runtime: &AgentRuntime, + questions: &UserQuestionAuthorizations, + request: bitfun_agent_runtime_ipc::RuntimeUserAnswersRequest, +) -> std::result::Result<(), RuntimeIpcError> { + let permitted = questions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .ready + .iter() + .any(|authorization| authorization.matches_request(&request)); + if !permitted { + return Err(RuntimeIpcError { + code: RuntimeIpcErrorCode::SessionMismatch, + message: "user-input request identity does not match the registered question" + .to_string(), + }); + } + + let submitted_authorization = UserQuestionAuthorization { + session_id: request.session_id.clone(), + turn_id: request.turn_id.clone(), + tool_id: request.tool_id.clone(), + registration_sequence: request.registration_sequence, + }; + runtime + .submit_user_answers(AgentUserAnswersRequest { + session_id: request.session_id, + turn_id: request.turn_id, + tool_id: request.tool_id.clone(), + registration_sequence: request.registration_sequence, + answers: request.answers, + }) + .await + .map_err(runtime_ipc_error)?; + + let mut questions = questions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + record_user_question_resolved(&mut questions, submitted_authorization); + Ok(()) +} + pub(crate) async fn run_service(workspace: PathBuf, expected_identity: String) -> Result<()> { bitfun_services_core::process_manager::contain_current_process_tree() .context("contain Shared Runtime process tree")?; @@ -1026,7 +1163,6 @@ pub(crate) async fn run_service(workspace: PathBuf, expected_identity: String) - .await .context("bind Shared Runtime IPC")?; let result = server.serve().await.context("serve Shared Runtime IPC"); - crate::shutdown_mcp_servers().await; result } @@ -1333,25 +1469,32 @@ fn core_ipc_error(error: bitfun_core::util::errors::BitFunError) -> RuntimeIpcEr #[cfg(test)] mod tests { use super::{ - await_permission_route, connect_existing, delete_owned_session, index_user_question, - invalidate_event_stream, owned_session_rename_request, permission_event_session, + await_permission_route, completed_session_fork_followup_error, connect_existing, + delete_owned_session, index_user_question, invalidate_event_stream, + merge_user_question_authorization, owned_session_rename_request, permission_event_session, permission_targets_session, project_subagent_link_route, project_user_question_route, - publish_event, reject_remote_lineage_scope, rename_owned_session, route_agent_event, - runtime_ipc_error, subscribe_session_events, SessionEventSenders, SubagentRoute, - SubagentRoutes, EVENT_BUFFER, + publish_event, reconcile_restored_user_questions, record_user_question_resolved, + reject_remote_lineage_scope, rename_owned_session, route_agent_event, runtime_ipc_error, + submit_authorized_user_answers, subscribe_session_events, SessionEventSenders, + SubagentRoute, SubagentRoutes, UserQuestionAuthorization, UserQuestionAuthorizationState, + EVENT_BUFFER, USER_QUESTION_RESOLVED_LIMIT, }; use bitfun_agent_runtime::sdk::{ - AgentRuntimeBuilder, AgentSessionCreateRequest, AgentSessionCreateResult, - AgentSessionDeleteRequest, AgentSessionListRequest, AgentSessionManagementPort, - AgentSessionRenameRequest, AgentSessionSummary, AgentSessionWorkspaceBinding, - AgentSessionWorkspaceRequest, AgentSubmissionPort, AgentSubmissionRequest, - AgentSubmissionResult, PermissionDelegationContext, PermissionReplySource, - PermissionRequest, PermissionRequestEvent, PermissionRequestSource, - PermissionRequestSourceKind, PortError, PortErrorKind, PortResult, RuntimeError, + AgentInteractionResponsePort, AgentRuntimeBuilder, AgentSessionCreateRequest, + AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionListRequest, + AgentSessionManagementPort, AgentSessionRenameRequest, AgentSessionSummary, + AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionPort, + AgentSubmissionRequest, AgentSubmissionResult, AgentUserAnswersRequest, PendingUserInput, + PermissionDelegationContext, PermissionReplySource, PermissionRequest, + PermissionRequestEvent, PermissionRequestSource, PermissionRequestSourceKind, PortError, + PortErrorKind, PortResult, RuntimeError, + }; + use bitfun_agent_runtime_ipc::{ + RuntimeIpcError, RuntimeIpcErrorCode, RuntimeSessionRenameRequest, + RuntimeUserAnswersRequest, }; - use bitfun_agent_runtime_ipc::{RuntimeIpcErrorCode, RuntimeSessionRenameRequest}; use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; - use std::collections::HashMap; + use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::{watch, Notify}; @@ -1367,6 +1510,21 @@ mod tests { assert_eq!(error.code, RuntimeIpcErrorCode::SessionMismatch); } + #[test] + fn completed_fork_followup_failures_require_authoritative_recovery() { + let error = completed_session_fork_followup_error( + "transcript", + RuntimeIpcError { + code: RuntimeIpcErrorCode::NotFound, + message: "transcript unavailable".to_string(), + }, + ); + + assert_eq!(error.code, RuntimeIpcErrorCode::OutcomeUnknown); + assert!(error.message.contains("Session fork completed")); + assert!(error.message.contains("inspect the Session list")); + } + #[test] fn shared_handler_steering_delegates_to_the_runtime_sdk() { let source = include_str!("shared_runtime.rs").replace("\r\n", "\n"); @@ -1390,6 +1548,19 @@ mod tests { rename_requests: Mutex>, } + struct RealUserInputPort { + manager: Arc, + } + + #[async_trait::async_trait] + impl AgentInteractionResponsePort for RealUserInputPort { + async fn submit_user_answers(&self, request: AgentUserAnswersRequest) -> PortResult<()> { + self.manager + .send_answer(request) + .map_err(|error| PortError::new(PortErrorKind::InvalidRequest, error.to_string())) + } + } + #[async_trait::async_trait] impl AgentSubmissionPort for RecordingSessionPort { async fn create_session( @@ -1900,8 +2071,8 @@ mod tests { } #[test] - fn user_question_answers_remain_scoped_to_the_routed_parent_session() { - let questions = Mutex::new(HashMap::new()); + fn user_question_answers_require_the_post_registration_ready_event() { + let questions = Mutex::new(UserQuestionAuthorizationState::default()); let mut started = AgenticEvent::ToolEvent { session_id: "child-session".to_string(), turn_id: "child-turn".to_string(), @@ -1921,13 +2092,358 @@ mod tests { if session_id == "parent-session" && turn_id == "parent-turn" )); index_user_question(&started, "parent-session", &questions); + assert!(questions.lock().expect("question index").ready.is_empty()); + + let ready = AgenticEvent::ToolEvent { + session_id: "parent-session".to_string(), + turn_id: "parent-turn".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputRequested { + identity: ToolEventIdentity::direct("question-1", "AskUserQuestion"), + registration_sequence: 1, + params: serde_json::json!({}), + }, + }; + index_user_question(&ready, "parent-session", &questions); + assert!(questions.lock().expect("question index").ready.contains( + &UserQuestionAuthorization { + session_id: "parent-session".to_string(), + turn_id: "parent-turn".to_string(), + tool_id: "question-1".to_string(), + registration_sequence: 1, + } + )); + } + + #[test] + fn user_question_settlement_revokes_only_the_exact_registration() { + let current = UserQuestionAuthorization { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + tool_id: "question-1".to_string(), + registration_sequence: 2, + }; + let questions = Mutex::new(UserQuestionAuthorizationState { + ready: HashSet::from([current.clone()]), + ..UserQuestionAuthorizationState::default() + }); + let settled = |registration_sequence| AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "pending-user-input:question-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputResolved { + identity: ToolEventIdentity::direct("question-1", "AskUserQuestion"), + registration_sequence, + }, + }; + + index_user_question(&settled(1), "session-1", &questions); + assert!(questions + .lock() + .expect("question index") + .ready + .contains(¤t)); + + index_user_question(&settled(2), "session-1", &questions); + assert!(questions.lock().expect("question index").ready.is_empty()); + } + + #[test] + fn user_question_resolved_tombstones_are_bounded_and_keep_recent_registrations() { + let mut questions = UserQuestionAuthorizationState::default(); + for registration_sequence in 1..=(USER_QUESTION_RESOLVED_LIMIT as u64 + 1) { + record_user_question_resolved( + &mut questions, + UserQuestionAuthorization { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + tool_id: "question-1".to_string(), + registration_sequence, + }, + ); + } + + let oldest = UserQuestionAuthorization { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + tool_id: "question-1".to_string(), + registration_sequence: 1, + }; + let newest = UserQuestionAuthorization { + registration_sequence: USER_QUESTION_RESOLVED_LIMIT as u64 + 1, + ..oldest.clone() + }; + assert_eq!(questions.resolved.len(), USER_QUESTION_RESOLVED_LIMIT); + assert!(!questions.resolved.contains(&oldest)); + assert!(questions.resolved.contains(&newest)); + + merge_user_question_authorization(&mut questions, newest.clone()); + assert!(!questions.ready.contains(&newest)); + } + + #[test] + fn restored_snapshot_cannot_revive_a_registration_resolved_before_reconcile() { + let pending = PendingUserInput { + tool_id: "question-1".to_string(), + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + source_session_id: "session-1".to_string(), + source_turn_id: "turn-1".to_string(), + registration_sequence: 7, + input: serde_json::json!({ "questions": [] }), + }; + let authorization = UserQuestionAuthorization::from_pending(&pending); + let questions = Mutex::new(UserQuestionAuthorizationState::default()); + + index_user_question( + &AgenticEvent::ToolEvent { + session_id: pending.session_id.clone(), + turn_id: pending.turn_id.clone(), + round_id: "pending-user-input:question-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputResolved { + identity: ToolEventIdentity::direct("question-1", "AskUserQuestion"), + registration_sequence: pending.registration_sequence, + }, + }, + &pending.session_id, + &questions, + ); + let session_id = pending.session_id.clone(); + reconcile_restored_user_questions(&session_id, &[pending], &questions); + + let questions = questions.lock().expect("question index"); + assert!(!questions.ready.contains(&authorization)); + assert!(questions.resolved.contains(&authorization)); + } + + #[test] + fn restored_pending_questions_merge_without_making_the_cache_authoritative() { + let questions = Mutex::new(UserQuestionAuthorizationState { + ready: HashSet::from([ + UserQuestionAuthorization { + session_id: "parent-session".to_string(), + turn_id: "stale-turn".to_string(), + tool_id: "stale-question".to_string(), + registration_sequence: 1, + }, + UserQuestionAuthorization { + session_id: "other-session".to_string(), + turn_id: "other-turn".to_string(), + tool_id: "other-question".to_string(), + registration_sequence: 1, + }, + ]), + ..UserQuestionAuthorizationState::default() + }); + let pending = vec![PendingUserInput { + tool_id: "question-1".to_string(), + session_id: "parent-session".to_string(), + turn_id: "parent-turn".to_string(), + source_session_id: "child-session".to_string(), + source_turn_id: "child-turn".to_string(), + registration_sequence: 1, + input: serde_json::json!({ "questions": [] }), + }]; + + reconcile_restored_user_questions("parent-session", &pending, &questions); + + let questions = questions.lock().expect("question index"); + assert!(questions + .ready + .contains(&UserQuestionAuthorization::from_pending(&pending[0]))); assert_eq!( questions - .lock() - .expect("question index") - .get("question-1") - .map(String::as_str), - Some("parent-session") + .ready + .iter() + .find(|authorization| authorization.tool_id == "stale-question") + .map(|authorization| authorization.session_id.as_str()), + Some("parent-session"), + ); + assert_eq!( + questions + .ready + .iter() + .find(|authorization| authorization.tool_id == "other-question") + .map(|authorization| authorization.session_id.as_str()), + Some("other-session"), + ); + drop(questions); + + // Merge-only reconciliation may retain a stale local authorization, + // but the Runtime-owned response channel remains authoritative and + // rejects an answer after that channel has gone away. + let runtime_owner = bitfun_agent_runtime::user_questions::UserInputManager::new(); + assert!(matches!( + runtime_owner.send_answer(AgentUserAnswersRequest { + session_id: "parent-session".to_string(), + turn_id: "stale-turn".to_string(), + tool_id: "stale-question".to_string(), + registration_sequence: 1, + answers: serde_json::json!({}), + }), + Err( + bitfun_agent_runtime::user_questions::UserInputSendError::MissingChannel { + tool_id + } + ) if tool_id == "stale-question" + )); + } + + #[test] + fn restored_pending_questions_preserve_ready_authorization_after_snapshot() { + let questions = Mutex::new(UserQuestionAuthorizationState { + ready: HashSet::from([UserQuestionAuthorization { + session_id: "parent-session".to_string(), + turn_id: "ready-turn".to_string(), + tool_id: "ready-after-snapshot".to_string(), + registration_sequence: 2, + }]), + ..UserQuestionAuthorizationState::default() + }); + let older_snapshot = vec![PendingUserInput { + tool_id: "snapshot-question".to_string(), + session_id: "parent-session".to_string(), + turn_id: "parent-turn".to_string(), + source_session_id: "parent-session".to_string(), + source_turn_id: "parent-turn".to_string(), + registration_sequence: 1, + input: serde_json::json!({ "questions": [] }), + }]; + + reconcile_restored_user_questions("parent-session", &older_snapshot, &questions); + + let questions = questions.lock().expect("question index"); + assert_eq!( + questions + .ready + .iter() + .find(|authorization| authorization.tool_id == "ready-after-snapshot") + .map(|authorization| authorization.session_id.as_str()), + Some("parent-session"), + ); + assert_eq!( + questions + .ready + .iter() + .find(|authorization| authorization.tool_id == "snapshot-question") + .map(|authorization| authorization.session_id.as_str()), + Some("parent-session"), + ); + } + + #[tokio::test] + async fn restored_old_identity_cannot_overwrite_or_answer_a_live_reused_tool_id() { + let owner = Arc::new(bitfun_agent_runtime::user_questions::UserInputManager::new()); + let (sender_a, receiver_a) = tokio::sync::oneshot::channel(); + let mut pending_a = PendingUserInput { + tool_id: "reused-tool".to_string(), + session_id: "session-a".to_string(), + turn_id: "turn-a".to_string(), + source_session_id: "session-a".to_string(), + source_turn_id: "turn-a".to_string(), + registration_sequence: 0, + input: serde_json::json!({ "questions": [] }), + }; + pending_a.registration_sequence = owner.register(pending_a.clone(), sender_a); + let old_a_snapshot = vec![pending_a.clone()]; + owner + .send_answer(AgentUserAnswersRequest { + session_id: pending_a.session_id.clone(), + turn_id: pending_a.turn_id.clone(), + tool_id: pending_a.tool_id.clone(), + registration_sequence: pending_a.registration_sequence, + answers: serde_json::json!({ "0": "answered-a" }), + }) + .expect("consume the old A channel before its snapshot is reconciled"); + assert_eq!( + receiver_a.await.expect("A receiver").answers, + serde_json::json!({ "0": "answered-a" }) + ); + + let (sender_b, receiver_b) = tokio::sync::oneshot::channel(); + let mut pending_b = PendingUserInput { + tool_id: "reused-tool".to_string(), + session_id: "session-b".to_string(), + turn_id: "turn-b".to_string(), + source_session_id: "session-b".to_string(), + source_turn_id: "turn-b".to_string(), + registration_sequence: 0, + input: serde_json::json!({ "questions": [] }), + }; + pending_b.registration_sequence = owner.register(pending_b.clone(), sender_b); + + let authorizations = Mutex::new(UserQuestionAuthorizationState::default()); + let ready_b = AgenticEvent::ToolEvent { + session_id: pending_b.session_id.clone(), + turn_id: pending_b.turn_id.clone(), + round_id: "round-b".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputRequested { + identity: ToolEventIdentity::direct(&pending_b.tool_id, "AskUserQuestion"), + registration_sequence: pending_b.registration_sequence, + params: pending_b.input.clone(), + }, + }; + index_user_question(&ready_b, &pending_b.session_id, &authorizations); + reconcile_restored_user_questions("session-a", &old_a_snapshot, &authorizations); + let authorizations_guard = authorizations.lock().expect("question authorizations"); + assert!(authorizations_guard + .ready + .contains(&UserQuestionAuthorization::from_pending(&pending_a))); + assert!(authorizations_guard + .ready + .contains(&UserQuestionAuthorization::from_pending(&pending_b))); + drop(authorizations_guard); + + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(Arc::new(RecordingSessionPort::default())) + .with_interaction_response_port(Arc::new(RealUserInputPort { + manager: owner.clone(), + })) + .build() + .expect("runtime"); + let stale_error = submit_authorized_user_answers( + &runtime, + &authorizations, + RuntimeUserAnswersRequest { + session_id: pending_a.session_id.clone(), + turn_id: pending_a.turn_id.clone(), + tool_id: pending_a.tool_id.clone(), + registration_sequence: pending_a.registration_sequence, + answers: serde_json::json!({ "0": "stale" }), + }, + ) + .await + .expect_err("old A authorization must fail closed"); + assert_eq!(stale_error.code, RuntimeIpcErrorCode::InvalidRequest); + assert!(owner.has_pending("reused-tool")); + + submit_authorized_user_answers( + &runtime, + &authorizations, + RuntimeUserAnswersRequest { + session_id: pending_b.session_id, + turn_id: pending_b.turn_id, + tool_id: pending_b.tool_id, + registration_sequence: pending_b.registration_sequence, + answers: serde_json::json!({ "0": "current" }), + }, + ) + .await + .expect("live B authorization should succeed"); + assert_eq!( + receiver_b + .await + .expect("B receiver should remain live") + .answers, + serde_json::json!({ "0": "current" }) ); } } diff --git a/src/apps/cli/src/shared_tui_backend.rs b/src/apps/cli/src/shared_tui_backend.rs deleted file mode 100644 index f4c515e4d8..0000000000 --- a/src/apps/cli/src/shared_tui_backend.rs +++ /dev/null @@ -1,1596 +0,0 @@ -//! CLI Host compatibility adapter from the private Shared Runtime IPC to TUI v2. - -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; - -use crate::tui_backend::{TuiBackend, TuiBackendError, TuiBackendErrorKind}; -use async_trait::async_trait; -use bitfun_agent_runtime_ipc::{ - RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode, - RuntimeIpcEvent, RuntimeIpcOperation, RuntimeIpcOperationResult, - RuntimeIpcStreamInvalidationReason, RuntimeSessionForkRequest, RuntimeSessionProcessingPhase, - RuntimeSessionRenameRequest, RuntimeSessionRestoreRequest, RuntimeSessionState, - RuntimeUserAnswersRequest, -}; -use bitfun_app_server::management::{ - ACCOUNT_CAPABILITY, EXTERNAL_HOOKS_CAPABILITY, EXTERNAL_SOURCES_CAPABILITY, MODES_CAPABILITY, - NATIVE_HOOKS_CAPABILITY, SETTINGS_SYNC_CAPABILITY, WORKTREES_CAPABILITY, -}; -use bitfun_app_server::{ - AppManagementCapabilities, AppManagementError, AppManagementErrorKind, AppManagementService, -}; -use bitfun_app_server_client::AppServerEvent; -use bitfun_app_server_protocol::account::*; -use bitfun_app_server_protocol::agent::*; -use bitfun_app_server_protocol::app::{ - CapabilityAvailability, CapabilityDescriptor, HealthResponse, HealthStatus, InitializeRequest, - InitializeResponse, ServerInfo, TransportLimits, -}; -use bitfun_app_server_protocol::event::{ - AgentEventNotification, EventCursor, EventStream, EventStreamState, - EventStreamStateNotification, PermissionEventNotification, ResyncDirective, -}; -use bitfun_app_server_protocol::external_source::*; -use bitfun_app_server_protocol::hook::*; -use bitfun_app_server_protocol::mcp::*; -use bitfun_app_server_protocol::model::*; -use bitfun_app_server_protocol::session::*; -use bitfun_app_server_protocol::skill::*; -use bitfun_app_server_protocol::subagent::*; -use bitfun_app_server_protocol::workspace::*; -use bitfun_app_server_protocol::worktree::*; -use bitfun_app_server_protocol::{MIN_PROTOCOL_VERSION, PROTOCOL_VERSION}; -use bitfun_runtime_ports::{ - AgentSessionCompactionResult, AgentSessionForkResult, AgentSessionWorkspaceBinding, - AgentSubmissionSource, AgentUserShellCommandResult, DialogSubmissionPolicy, -}; -use tokio::sync::broadcast; - -const EVENT_BUFFER: usize = 256; - -pub(crate) struct SharedTuiBackend { - client: RuntimeIpcClient, - management: Arc, - local_management_scope: Arc, - current_session_id: Arc>>, - events: broadcast::Sender, -} - -impl SharedTuiBackend { - pub(crate) fn new(client: RuntimeIpcClient, management: Arc) -> Self { - let (events, _) = broadcast::channel(EVENT_BUFFER); - let connection_id = format!("shared-runtime-{}", uuid::Uuid::new_v4()); - spawn_event_bridge( - client.subscribe_events(), - events.clone(), - connection_id.clone(), - ); - spawn_external_source_event_bridge( - management.subscribe_external_source_updates(), - events.clone(), - connection_id, - ); - Self { - client, - management, - local_management_scope: Arc::new(AtomicBool::new(true)), - current_session_id: Arc::new(Mutex::new(None)), - events, - } - } - - fn set_current_session(&self, session_id: impl Into) { - *self - .current_session_id - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(session_id.into()); - } - - fn set_management_scope_from_binding(&self, binding: &AgentSessionWorkspaceBinding) { - self.local_management_scope.store( - binding.remote_connection_id.is_none() && binding.remote_ssh_host.is_none(), - Ordering::Relaxed, - ); - } - - fn current_session(&self) -> Result { - self.current_session_id - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone() - .ok_or_else(|| backend_error("Shared TUI has no attached Session", false)) - } - - async fn request( - &self, - operation: RuntimeIpcOperation, - ) -> Result { - self.client - .request(operation) - .await - .map_err(map_client_error) - } - - fn management_service( - &self, - capability: &str, - ) -> Result<&AppManagementService, TuiBackendError> { - require_local_management_scope( - self.local_management_scope.load(Ordering::Relaxed), - capability, - )?; - match self.management.capabilities().availability(capability) { - Some(CapabilityAvailability::Available) => Ok(self.management.as_ref()), - Some(CapabilityAvailability::Unavailable { reason }) => Err(TuiBackendError { - message: reason.clone(), - outcome_unknown: false, - kind: TuiBackendErrorKind::Unsupported { - capability: capability.to_string(), - }, - }), - None => Err(TuiBackendError { - message: format!( - "{capability} is not declared by the App Server management service" - ), - outcome_unknown: false, - kind: TuiBackendErrorKind::Unsupported { - capability: capability.to_string(), - }, - }), - } - } -} - -fn require_local_management_scope(local: bool, capability: &str) -> Result<(), TuiBackendError> { - if local { - return Ok(()); - } - Err(TuiBackendError { - message: format!( - "{capability} is unavailable for a Remote workspace; the Shared CLI adapter does not fall back to its local management service" - ), - outcome_unknown: false, - kind: TuiBackendErrorKind::Unsupported { - capability: capability.to_string(), - }, - }) -} - -#[async_trait] -impl TuiBackend for SharedTuiBackend { - async fn initialize( - &self, - request: InitializeRequest, - ) -> Result { - if request.protocol_version < MIN_PROTOCOL_VERSION - || request.protocol_version > PROTOCOL_VERSION - { - return Err(backend_error( - format!( - "Unsupported TUI protocol {}, expected {}", - request.protocol_version, PROTOCOL_VERSION - ), - false, - )); - } - if !self.client.capabilities().interactive_tui { - return Err(backend_error( - "Shared Runtime does not advertise interactive TUI support", - false, - )); - } - Ok(InitializeResponse::new( - ServerInfo { - name: "bitfun-shared-runtime-host-adapter".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - tui_capabilities(&self.management.capabilities()), - TransportLimits { - max_frame_bytes: 16 * 1024 * 1024, - event_buffer_capacity: EVENT_BUFFER as u32, - }, - )) - } - - async fn model_catalog(&self) -> Result { - load_model_catalog().await - } - - async fn account_snapshot( - &self, - request: AccountSnapshotRequest, - ) -> Result { - self.management_service(ACCOUNT_CAPABILITY)? - .account_snapshot(request) - .await - .map_err(|error| map_management_error(ACCOUNT_CAPABILITY, error)) - } - - async fn account_login( - &self, - request: AccountLoginRequest, - ) -> Result { - self.management_service(ACCOUNT_CAPABILITY)? - .account_login(request) - .await - .map_err(|error| map_management_error(ACCOUNT_CAPABILITY, error)) - } - - async fn account_finalize_login( - &self, - request: AccountFinalizeLoginRequest, - ) -> Result { - self.management_service(ACCOUNT_CAPABILITY)? - .account_finalize_login(request) - .await - .map_err(|error| map_management_error(ACCOUNT_CAPABILITY, error)) - } - - async fn account_logout( - &self, - request: AccountLogoutRequest, - ) -> Result { - self.management_service(ACCOUNT_CAPABILITY)? - .account_logout(request) - .await - .map_err(|error| map_management_error(ACCOUNT_CAPABILITY, error)) - } - - async fn settings_sync_start( - &self, - request: SettingsSyncStartRequest, - ) -> Result { - self.management_service(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_start(request) - .await - .map_err(|error| map_management_error(SETTINGS_SYNC_CAPABILITY, error)) - } - - async fn settings_sync_snapshot( - &self, - request: SettingsSyncSnapshotRequest, - ) -> Result { - self.management_service(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_snapshot(request) - .await - .map_err(|error| map_management_error(SETTINGS_SYNC_CAPABILITY, error)) - } - - async fn settings_sync_cancel( - &self, - request: SettingsSyncCancelRequest, - ) -> Result { - self.management_service(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_cancel(request) - .await - .map_err(|error| map_management_error(SETTINGS_SYNC_CAPABILITY, error)) - } - - async fn settings_sync_local_changed( - &self, - request: SettingsSyncLocalChangedRequest, - ) -> Result { - self.management_service(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_local_changed(request) - .await - .map_err(|error| map_management_error(SETTINGS_SYNC_CAPABILITY, error)) - } - - async fn worktree_repository_status( - &self, - request: WorktreeRepositoryStatusRequest, - ) -> Result { - self.management_service(WORKTREES_CAPABILITY)? - .worktree_repository_status(request) - .await - .map_err(|error| map_management_error(WORKTREES_CAPABILITY, error)) - } - - async fn worktree_bind_session( - &self, - request: WorktreeBindSessionRequest, - ) -> Result { - self.management_service(WORKTREES_CAPABILITY)? - .worktree_bind_session(request) - .await - .map_err(|error| map_management_error(WORKTREES_CAPABILITY, error)) - } - - async fn worktree_release_session( - &self, - request: WorktreeReleaseSessionRequest, - ) -> Result { - self.management_service(WORKTREES_CAPABILITY)? - .worktree_release_session(request) - .await - .map_err(|error| map_management_error(WORKTREES_CAPABILITY, error)) - } - - async fn health(&self) -> Result { - self.client.health().await.map_err(map_client_error)?; - Ok(HealthResponse { - status: HealthStatus::Ready, - protocol_version: PROTOCOL_VERSION, - }) - } - - fn subscribe_events(&self) -> broadcast::Receiver { - self.events.subscribe() - } - - async fn list_sessions( - &self, - request: ListSessionsRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::ListSessions { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::Sessions { sessions } => { - Ok(ListSessionsResponse { sessions }) - } - other => Err(unexpected("list_sessions", other)), - } - } - - async fn sync_session( - &self, - request: SyncSessionRequest, - ) -> Result { - let requested_session_id = request.session_id.clone(); - match self - .request(RuntimeIpcOperation::RestoreSession { - request: RuntimeSessionRestoreRequest { - workspace_path: request.workspace_path, - session_id: requested_session_id.clone(), - }, - }) - .await? - { - RuntimeIpcOperationResult::SessionRestored { - session, - state, - workspace_binding, - transcript, - pending_permissions, - } => { - self.set_current_session(requested_session_id); - self.set_management_scope_from_binding(&workspace_binding); - Ok(SyncSessionResponse { - session, - state: map_session_state(state), - transcript, - workspace_binding, - pending_permissions, - }) - } - other => Err(unexpected("sync_session", other)), - } - } - - async fn create_session( - &self, - request: CreateSessionRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::CreateSession { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::SessionCreated { session } => { - self.set_current_session(session.session_id.clone()); - self.local_management_scope.store(true, Ordering::Relaxed); - Ok(CreateSessionResponse(session)) - } - other => Err(unexpected("create_session", other)), - } - } - - async fn delete_session( - &self, - request: DeleteSessionRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::DeleteSession { - session_id: request.0.session_id, - }) - .await?, - "delete_session", - )?; - Ok(DeleteSessionResponse {}) - } - - async fn rename_session( - &self, - request: RenameSessionRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::RenameSession { - request: RuntimeSessionRenameRequest { - session_id: request.0.session_id, - session_name: request.0.session_name, - }, - }) - .await?, - "rename_session", - )?; - Ok(RenameSessionResponse {}) - } - - async fn submit_dialog_turn( - &self, - request: SubmitDialogTurnRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::SubmitTurn { - request: request.0.to_request(DialogSubmissionPolicy::for_source( - AgentSubmissionSource::Cli, - )), - }) - .await? - { - RuntimeIpcOperationResult::TurnAccepted { - session_id, - turn_id, - } => Ok(SubmitDialogTurnResponse::Started { - session_id, - turn_id, - }), - other => Err(unexpected("submit_dialog_turn", other)), - } - } - - async fn cancel_turn( - &self, - request: CancelTurnRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::CancelTurn { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::TurnCancelled { cancellation } => { - Ok(CancelTurnResponse(cancellation)) - } - other => Err(unexpected("cancel_turn", other)), - } - } - - async fn steer_turn( - &self, - request: SteerTurnRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::SteerTurn { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::TurnSteered { steering_id, .. } => { - Ok(SteerTurnResponse { steering_id }) - } - other => Err(unexpected("steer_turn", other)), - } - } - - async fn run_user_shell_command( - &self, - request: RunUserShellCommandRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::RunUserShellCommand { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::TurnAccepted { - session_id, - turn_id, - } => Ok(RunUserShellCommandResponse(AgentUserShellCommandResult { - session_id, - turn_id, - })), - other => Err(unexpected("run_user_shell_command", other)), - } - } - - async fn submit_user_answers( - &self, - request: SubmitUserAnswersRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::SubmitUserAnswers { - request: RuntimeUserAnswersRequest { - session_id: self.current_session()?, - tool_id: request.tool_id, - answers: request.answers, - }, - }) - .await?, - "submit_user_answers", - )?; - Ok(SubmitUserAnswersResponse {}) - } - - async fn respond_permission( - &self, - request: RespondPermissionRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::RespondPermission { - session_id: self.current_session()?, - request_id: request.request_id, - reply: request.reply, - }) - .await?, - "respond_permission", - )?; - Ok(RespondPermissionResponse {}) - } - - async fn pending_permissions(&self) -> Result { - let Some(session_id) = self - .current_session_id - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone() - else { - return Ok(PendingPermissionsResponse { - requests: Vec::new(), - }); - }; - match self - .request(RuntimeIpcOperation::PendingPermissions { session_id }) - .await? - { - RuntimeIpcOperationResult::PendingPermissions { requests } => { - Ok(PendingPermissionsResponse { requests }) - } - other => Err(unexpected("pending_permissions", other)), - } - } - - async fn compact_session( - &self, - request: CompactSessionRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::CompactSession { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::TurnAccepted { - session_id, - turn_id, - } => Ok(CompactSessionResponse(AgentSessionCompactionResult { - session_id, - turn_id, - })), - other => Err(unexpected("compact_session", other)), - } - } - - async fn undo_session( - &self, - request: UndoSessionRequest, - ) -> Result { - map_revert( - self.request(RuntimeIpcOperation::UndoSession { request: request.0 }) - .await?, - "undo_session", - ) - } - - async fn redo_session( - &self, - request: RedoSessionRequest, - ) -> Result { - map_revert( - self.request(RuntimeIpcOperation::RedoSession { request: request.0 }) - .await?, - "redo_session", - ) - } - - async fn reload_context( - &self, - request: ReloadContextRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::ReloadSessionContext { request: request.0 }) - .await?, - "reload_context", - )?; - Ok(ReloadContextResponse {}) - } - - async fn session_usage( - &self, - request: SessionUsageRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::SessionUsage { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::SessionUsage { usage } => Ok(SessionUsageResponse(usage)), - other => Err(unexpected("session_usage", other)), - } - } - - async fn wait_for_settlement( - &self, - request: WaitForSettlementRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::WaitForSettlement { request: request.0 }) - .await?, - "wait_for_settlement", - )?; - Ok(WaitForSettlementResponse {}) - } - - async fn workspace_diff(&self) -> Result { - match self.request(RuntimeIpcOperation::WorkspaceDiff).await? { - RuntimeIpcOperationResult::WorkspaceDiff { snapshot } => { - Ok(WorkspaceDiffResponse(snapshot)) - } - other => Err(unexpected("workspace_diff", other)), - } - } - - async fn search_workspace_references( - &self, - request: SearchWorkspaceReferencesRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::SearchWorkspaceReferences { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::WorkspaceReferenceSearch { search } => { - Ok(SearchWorkspaceReferencesResponse(search)) - } - other => Err(unexpected("search_workspace_references", other)), - } - } - - async fn message_references( - &self, - request: MessageReferencesRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::WorkspaceReferencesForMessage { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::WorkspaceReferences { references } => { - Ok(MessageReferencesResponse(references)) - } - other => Err(unexpected("message_references", other)), - } - } - - async fn session_lineage( - &self, - request: SessionLineageRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::GetSessionLineage { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::SessionLineage { snapshot } => { - Ok(SessionLineageResponse(snapshot)) - } - other => Err(unexpected("session_lineage", other)), - } - } - - async fn inspect_lineage( - &self, - request: InspectLineageRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::InspectLineageSession { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::LineageSessionInspection { inspection } => { - Ok(InspectLineageResponse(inspection)) - } - other => Err(unexpected("inspect_lineage", other)), - } - } - - async fn cancel_lineage( - &self, - request: CancelLineageRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::CancelLineageSession { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::TurnCancelled { cancellation } => { - Ok(CancelLineageResponse(cancellation)) - } - other => Err(unexpected("cancel_lineage", other)), - } - } - - async fn fork_session( - &self, - request: ForkSessionRequest, - ) -> Result { - self.fork(request.0.source_session_id, None).await - } - - async fn fork_session_before_turn( - &self, - request: ForkSessionBeforeTurnRequest, - ) -> Result { - self.fork(request.0.source_session_id, Some(request.0.source_turn_id)) - .await - } - - async fn update_session_model( - &self, - request: UpdateSessionModelRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::UpdateSessionModel { request: request.0 }) - .await?, - "update_session_model", - )?; - Ok(UpdateSessionModelResponse {}) - } - - async fn update_session_mode( - &self, - request: UpdateSessionModeRequest, - ) -> Result { - expect_unit( - self.request(RuntimeIpcOperation::UpdateSessionMode { request: request.0 }) - .await?, - "update_session_mode", - )?; - Ok(UpdateSessionModeResponse {}) - } - - async fn list_agent_modes( - &self, - _request: ListAgentModesRequest, - ) -> Result { - let session_id = self - .current_session_id - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); - match self - .request(RuntimeIpcOperation::ListAgentModes { session_id }) - .await? - { - RuntimeIpcOperationResult::AgentModes { modes } => Ok(ListAgentModesResponse { - modes: modes - .into_iter() - .map(|mode| AgentModeSummary { - id: mode.id, - description: mode.description, - model_id: mode.model_id, - is_external: mode.is_external, - }) - .collect(), - }), - other => Err(unexpected("list_agent_modes", other)), - } - } - - async fn list_models(&self) -> Result { - self.management_service("tui.models")? - .list_models(ListModelsRequest {}) - .await - .map_err(|error| map_management_error("tui.models", error)) - } - - async fn get_model( - &self, - request: GetModelRequest, - ) -> Result { - self.management_service("tui.models")? - .get_model(request) - .await - .map_err(|error| map_management_error("tui.models", error)) - } - - async fn add_model( - &self, - request: AddModelRequest, - ) -> Result { - self.management_service("tui.models")? - .add_model(request) - .await - .map_err(|error| map_management_error("tui.models", error)) - } - - async fn update_model( - &self, - request: UpdateModelRequest, - ) -> Result { - self.management_service("tui.models")? - .update_model(request) - .await - .map_err(|error| map_management_error("tui.models", error)) - } - - async fn delete_model( - &self, - request: DeleteModelRequest, - ) -> Result { - self.management_service("tui.models")? - .delete_model(request) - .await - .map_err(|error| map_management_error("tui.models", error)) - } - - async fn set_model_default( - &self, - request: SetModelDefaultRequest, - ) -> Result { - self.management_service("tui.models")? - .set_model_default(request) - .await - .map_err(|error| map_management_error("tui.models", error)) - } - - async fn list_skills( - &self, - request: ListSkillsRequest, - ) -> Result { - self.management_service("tui.skills")? - .list_skills(request) - .await - .map_err(|error| map_management_error("tui.skills", error)) - } - - async fn set_skill_enabled( - &self, - request: SetSkillEnabledRequest, - ) -> Result { - self.management_service("tui.skills")? - .set_skill_enabled(request) - .await - .map_err(|error| map_management_error("tui.skills", error)) - } - - async fn list_subagents( - &self, - request: ListSubagentsRequest, - ) -> Result { - self.management_service("tui.subagents")? - .list_subagents(request) - .await - .map_err(|error| map_management_error("tui.subagents", error)) - } - - async fn set_subagent_enabled( - &self, - request: SetSubagentEnabledRequest, - ) -> Result { - self.management_service("tui.subagents")? - .set_subagent_enabled(request) - .await - .map_err(|error| map_management_error("tui.subagents", error)) - } - - async fn list_mcp_servers( - &self, - request: ListMcpServersRequest, - ) -> Result { - self.management_service("tui.mcp")? - .list_mcp_servers(request) - .await - .map_err(|error| map_management_error("tui.mcp", error)) - } - - async fn toggle_mcp_server( - &self, - request: ToggleMcpServerRequest, - ) -> Result { - self.management_service("tui.mcp")? - .toggle_mcp_server(request) - .await - .map_err(|error| map_management_error("tui.mcp", error)) - } - - async fn add_mcp_server( - &self, - request: AddMcpServerRequest, - ) -> Result { - self.management_service("tui.mcp")? - .add_mcp_server(request) - .await - .map_err(|error| map_management_error("tui.mcp", error)) - } - - async fn delete_mcp_server( - &self, - request: DeleteMcpServerRequest, - ) -> Result { - self.management_service("tui.mcp")? - .delete_mcp_server(request) - .await - .map_err(|error| map_management_error("tui.mcp", error)) - } - - async fn external_mcp_decision( - &self, - request: ExternalMcpDecisionRequest, - ) -> Result { - self.management_service("tui.mcp")? - .external_mcp_decision(request) - .await - .map_err(|error| map_management_error("tui.mcp", error)) - } - - async fn mcp_conflict_choice( - &self, - request: McpConflictChoiceRequest, - ) -> Result { - self.management_service("tui.mcp")? - .mcp_conflict_choice(request) - .await - .map_err(|error| map_management_error("tui.mcp", error)) - } - - async fn external_source_snapshot( - &self, - request: ExternalSourceSnapshotRequest, - ) -> Result { - self.management_service(EXTERNAL_SOURCES_CAPABILITY)? - .external_source_snapshot(request) - .await - .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) - } - - async fn external_source_control( - &self, - request: ExternalSourceControlRequest, - ) -> Result { - self.management_service(EXTERNAL_SOURCES_CAPABILITY)? - .external_source_control(request) - .await - .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) - } - - async fn external_source_review( - &self, - request: ExternalSourceReviewRequest, - ) -> Result { - self.management_service(EXTERNAL_SOURCES_CAPABILITY)? - .external_source_review(request) - .await - .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) - } - - async fn set_native_command_choice( - &self, - request: SetNativeCommandChoiceRequest, - ) -> Result { - self.management_service(EXTERNAL_SOURCES_CAPABILITY)? - .set_native_command_choice(request) - .await - .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) - } - - async fn expand_external_command( - &self, - request: ExpandExternalCommandRequest, - ) -> Result { - self.management_service(EXTERNAL_SOURCES_CAPABILITY)? - .expand_external_command(request) - .await - .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) - } - - async fn native_hook_overview( - &self, - request: NativeHookOverviewRequest, - ) -> Result { - self.management_service(NATIVE_HOOKS_CAPABILITY)? - .native_hook_overview(request) - .await - .map_err(|error| map_management_error(NATIVE_HOOKS_CAPABILITY, error)) - } - - async fn external_hook_snapshot( - &self, - request: ExternalHookSnapshotRequest, - ) -> Result { - self.management_service(EXTERNAL_HOOKS_CAPABILITY)? - .external_hook_snapshot(request) - .await - .map_err(|error| map_management_error(EXTERNAL_HOOKS_CAPABILITY, error)) - } - - async fn external_hook_plan( - &self, - request: ExternalHookPlanRequest, - ) -> Result { - self.management_service(EXTERNAL_HOOKS_CAPABILITY)? - .external_hook_plan(request) - .await - .map_err(|error| map_management_error(EXTERNAL_HOOKS_CAPABILITY, error)) - } - - async fn external_hook_apply( - &self, - request: ExternalHookApplyRequest, - ) -> Result { - self.management_service(EXTERNAL_HOOKS_CAPABILITY)? - .external_hook_apply(request) - .await - .map_err(|error| map_management_error(EXTERNAL_HOOKS_CAPABILITY, error)) - } - - async fn external_hook_mutate( - &self, - request: ExternalHookMutationRequest, - ) -> Result { - self.management_service(EXTERNAL_HOOKS_CAPABILITY)? - .external_hook_mutate(request) - .await - .map_err(|error| map_management_error(EXTERNAL_HOOKS_CAPABILITY, error)) - } -} - -impl SharedTuiBackend { - async fn fork( - &self, - source_session_id: String, - before_turn_id: Option, - ) -> Result { - match self - .request(RuntimeIpcOperation::ForkSession { - request: RuntimeSessionForkRequest { - session_id: source_session_id, - before_turn_id, - }, - }) - .await? - { - RuntimeIpcOperationResult::SessionForked { - session, - workspace_binding, - .. - } => { - self.set_current_session(session.session_id.clone()); - self.set_management_scope_from_binding(&workspace_binding); - Ok(ForkSessionResponse(AgentSessionForkResult { - session_id: session.session_id, - session_name: session.session_name, - agent_type: session.agent_type, - })) - } - other => Err(unexpected("fork_session", other)), - } - } -} - -async fn load_model_catalog() -> Result { - let catalog = bitfun_core::get_ai_model_catalog() - .await - .map_err(|message| backend_error(message, false))?; - let reasoning_presets_by_model = catalog - .models - .into_iter() - .filter_map(|model| { - model.reasoning.map(|reasoning| { - ( - model.id, - reasoning - .presets - .into_iter() - .map(|preset| preset.id) - .collect(), - ) - }) - }) - .collect(); - Ok(TuiModelCatalogResponse { - provider_catalog: catalog.provider_catalog, - reasoning_presets_by_model, - }) -} - -fn map_revert( - result: RuntimeIpcOperationResult, - operation: &str, -) -> Result { - match result { - RuntimeIpcOperationResult::SessionReverted { revert } => Ok(RevertSessionResponse(revert)), - other => Err(unexpected(operation, other)), - } -} - -fn expect_unit(result: RuntimeIpcOperationResult, operation: &str) -> Result<(), TuiBackendError> { - match result { - RuntimeIpcOperationResult::Unit => Ok(()), - other => Err(unexpected(operation, other)), - } -} - -fn unexpected(operation: &str, result: RuntimeIpcOperationResult) -> TuiBackendError { - backend_error( - format!("Shared Runtime returned an unexpected result for {operation}: {result:?}"), - true, - ) -} - -fn map_client_error(error: RuntimeIpcClientError) -> TuiBackendError { - let outcome_unknown = matches!( - &error, - RuntimeIpcClientError::Remote(remote) - if remote.code == RuntimeIpcErrorCode::OutcomeUnknown - ) || matches!( - error, - RuntimeIpcClientError::Timeout - | RuntimeIpcClientError::Disconnected - | RuntimeIpcClientError::UnexpectedResponse - | RuntimeIpcClientError::Io(_) - ); - backend_error(error.to_string(), outcome_unknown) -} - -fn backend_error(message: impl Into, outcome_unknown: bool) -> TuiBackendError { - TuiBackendError { - message: message.into(), - outcome_unknown, - kind: TuiBackendErrorKind::Backend, - } -} - -fn map_management_error(capability: &str, error: AppManagementError) -> TuiBackendError { - let kind = match error.kind { - AppManagementErrorKind::Unsupported => TuiBackendErrorKind::Unsupported { - capability: capability.to_string(), - }, - AppManagementErrorKind::InvalidRequest - | AppManagementErrorKind::NotFound - | AppManagementErrorKind::Internal => TuiBackendErrorKind::Backend, - }; - TuiBackendError { - message: error.message, - outcome_unknown: false, - kind, - } -} - -fn map_session_state(state: RuntimeSessionState) -> SessionRuntimeState { - match state { - RuntimeSessionState::Idle => SessionRuntimeState::Idle, - RuntimeSessionState::Processing { - current_turn_id, - phase, - } => SessionRuntimeState::Processing { - current_turn_id, - phase: match phase { - RuntimeSessionProcessingPhase::Starting => SessionProcessingPhase::Starting, - RuntimeSessionProcessingPhase::Compacting => SessionProcessingPhase::Compacting, - RuntimeSessionProcessingPhase::Thinking => SessionProcessingPhase::Thinking, - RuntimeSessionProcessingPhase::Streaming => SessionProcessingPhase::Streaming, - RuntimeSessionProcessingPhase::ToolCalling => SessionProcessingPhase::ToolCalling, - RuntimeSessionProcessingPhase::ToolConfirming => { - SessionProcessingPhase::ToolConfirming - } - }, - }, - RuntimeSessionState::Error { error, recoverable } => { - SessionRuntimeState::Error { error, recoverable } - } - } -} - -fn tui_capabilities(management: &AppManagementCapabilities) -> Vec { - let mut capabilities = [ - ( - "agent", - vec![ - "agent/createSession", - "agent/listSessions", - "agent/deleteSession", - "agent/submitDialogTurn", - "agent/steerTurn", - "agent/runUserShellCommand", - "agent/submitUserAnswers", - "agent/cancelTurn", - "agent/event", - ], - ), - ( - "session", - vec![ - "session/sync", - "session/rename", - "session/updateModel", - "session/updateMode", - "session/fork", - "session/forkBeforeTurn", - "session/compact", - "session/undo", - "session/redo", - "session/reloadContext", - "session/usage", - "session/waitForSettlement", - "session/lineage", - "session/inspectLineage", - "session/cancelLineage", - ], - ), - ( - "permission", - vec![ - "agent/permissionEvent", - "agent/respondPermission", - "agent/listPendingPermissionRequests", - ], - ), - ( - "workspace", - vec![ - "workspace/diff", - "workspace/searchReferences", - "workspace/messageReferences", - ], - ), - ] - .into_iter() - .map(|(id, methods)| CapabilityDescriptor { - id: id.to_string(), - availability: CapabilityAvailability::Available, - methods: methods.into_iter().map(str::to_string).collect(), - }) - .collect::>(); - capabilities.push(CapabilityDescriptor { - id: "tui.modes".to_string(), - availability: CapabilityAvailability::Available, - methods: vec!["agent/listModes".to_string()], - }); - capabilities.extend( - management - .descriptors() - .into_iter() - .filter(|descriptor| descriptor.id != MODES_CAPABILITY), - ); - capabilities -} - -fn spawn_event_bridge( - mut source: broadcast::Receiver, - output: broadcast::Sender, - connection_id: String, -) { - tokio::spawn(async move { - let agent_sequence = AtomicU64::new(0); - let permission_sequence = AtomicU64::new(0); - loop { - match source.recv().await { - Ok(RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::Agent { envelope, .. })) => { - let _ = output.send(AppServerEvent::Agent(AgentEventNotification { - cursor: next_cursor(&connection_id, EventStream::Agent, &agent_sequence), - event: envelope, - })); - } - Ok(RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::Permission { - event, .. - })) => { - let _ = output.send(AppServerEvent::Permission(PermissionEventNotification { - cursor: next_cursor( - &connection_id, - EventStream::Permission, - &permission_sequence, - ), - event, - })); - } - Ok(RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::StreamInvalidated { - reason, - })) => { - let _ = output.send(stream_state_event( - &connection_id, - &agent_sequence, - EventStreamState::Invalidated, - Some(invalidation_reason(reason)), - )); - break; - } - Ok(RuntimeIpcClientEvent::Disconnected) - | Err(broadcast::error::RecvError::Closed) => { - let _ = output.send(AppServerEvent::ConnectionClosed); - break; - } - Err(broadcast::error::RecvError::Lagged(missed)) => { - let mut event = stream_state_event( - &connection_id, - &agent_sequence, - EventStreamState::Lagged, - Some("Shared Runtime client event receiver lagged".to_string()), - ); - if let AppServerEvent::StreamState(notification) = &mut event { - notification.missed = Some(missed); - } - let _ = output.send(event); - break; - } - } - } - }); -} - -fn spawn_external_source_event_bridge( - mut source: broadcast::Receiver<( - String, - bitfun_product_domains::external_sources::ExternalSourcePublicSnapshot, - )>, - output: broadcast::Sender, - connection_id: String, -) { - tokio::spawn(async move { - let sequence = AtomicU64::new(0); - loop { - match source.recv().await { - Ok((workspace_path, snapshot)) => { - let _ = output.send(AppServerEvent::ExternalSource( - ExternalSourceEventNotification { - cursor: next_cursor( - &connection_id, - EventStream::ExternalSource, - &sequence, - ), - workspace_path, - snapshot, - }, - )); - } - Err(broadcast::error::RecvError::Lagged(missed)) => { - let _ = - output.send(AppServerEvent::StreamState(EventStreamStateNotification { - cursor: next_cursor( - &connection_id, - EventStream::ExternalSource, - &sequence, - ), - stream: EventStream::ExternalSource, - state: EventStreamState::Lagged, - missed: Some(missed), - resync: ResyncDirective { - method: "externalSource/snapshot".to_string(), - snapshot_available: true, - reason: Some( - "Shared external source event receiver lagged".to_string(), - ), - }, - })); - } - Err(broadcast::error::RecvError::Closed) => break, - } - } - }); -} - -fn next_cursor(connection_id: &str, stream: EventStream, sequence: &AtomicU64) -> EventCursor { - EventCursor { - connection_id: connection_id.to_string(), - stream, - sequence: sequence.fetch_add(1, Ordering::Relaxed) + 1, - } -} - -fn stream_state_event( - connection_id: &str, - sequence: &AtomicU64, - state: EventStreamState, - reason: Option, -) -> AppServerEvent { - AppServerEvent::StreamState(EventStreamStateNotification { - cursor: next_cursor(connection_id, EventStream::Agent, sequence), - stream: EventStream::Agent, - state, - missed: None, - resync: ResyncDirective { - method: "session/sync".to_string(), - snapshot_available: true, - reason, - }, - }) -} - -fn invalidation_reason(reason: RuntimeIpcStreamInvalidationReason) -> String { - match reason { - RuntimeIpcStreamInvalidationReason::Lagged => "Shared Runtime event stream lagged", - RuntimeIpcStreamInvalidationReason::Closed => "Shared Runtime event stream closed", - RuntimeIpcStreamInvalidationReason::FrameTooLarge => { - "Shared Runtime event exceeded the transport frame limit" - } - } - .to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; - - #[test] - fn shared_management_capabilities_follow_the_local_management_service() { - let capabilities = tui_capabilities(&AppManagementCapabilities::available()); - for id in [ - "tui.models", - "tui.skills", - "tui.subagents", - "tui.mcp", - "tui.externalSources", - "tui.nativeHooks", - "tui.externalHooks", - ACCOUNT_CAPABILITY, - SETTINGS_SYNC_CAPABILITY, - WORKTREES_CAPABILITY, - ] { - let capability = capabilities - .iter() - .find(|capability| capability.id == id) - .expect("management capability should be declared"); - assert_eq!(capability.availability, CapabilityAvailability::Available); - assert!(!capability.methods.is_empty()); - } - } - - #[test] - fn shared_management_preserves_availability_and_error_kind() { - let mut management = AppManagementCapabilities::available(); - management.mcp = CapabilityAvailability::Unavailable { - reason: "local MCP compatibility owner unavailable".to_string(), - }; - let capabilities = tui_capabilities(&management); - let mcp = capabilities - .iter() - .find(|capability| capability.id == "tui.mcp") - .expect("MCP capability"); - assert!(matches!( - mcp.availability, - CapabilityAvailability::Unavailable { .. } - )); - - let error = map_management_error( - "tui.mcp", - AppManagementError::unsupported("MCP compatibility owner unavailable"), - ); - assert_eq!( - error.kind, - TuiBackendErrorKind::Unsupported { - capability: "tui.mcp".to_string() - } - ); - assert!(!error.outcome_unknown); - - let error = map_management_error( - "tui.models", - AppManagementError::invalid_request("invalid model mutation"), - ); - assert_eq!(error.kind, TuiBackendErrorKind::Backend); - assert!(!error.outcome_unknown); - } - - #[test] - fn remote_workspace_cannot_use_the_local_management_service() { - let remote_error = require_local_management_scope(false, "tui.models") - .expect_err("Remote workspace must not use the local service"); - assert_eq!( - remote_error.kind, - TuiBackendErrorKind::Unsupported { - capability: "tui.models".to_string() - } - ); - assert!(remote_error.message.contains("Remote workspace")); - assert!(remote_error.message.contains("does not fall back")); - - let external_error = require_local_management_scope(false, EXTERNAL_SOURCES_CAPABILITY) - .expect_err("Remote external sources must not use the local service"); - assert_eq!( - external_error.kind, - TuiBackendErrorKind::Unsupported { - capability: EXTERNAL_SOURCES_CAPABILITY.to_string() - } - ); - - for capability in [NATIVE_HOOKS_CAPABILITY, EXTERNAL_HOOKS_CAPABILITY] { - let hook_error = require_local_management_scope(false, capability) - .expect_err("Remote Hook management must not use the local service"); - assert_eq!( - hook_error.kind, - TuiBackendErrorKind::Unsupported { - capability: capability.to_string() - } - ); - assert!(hook_error.message.contains("does not fall back")); - } - - for capability in [ACCOUNT_CAPABILITY, SETTINGS_SYNC_CAPABILITY] { - let account_error = require_local_management_scope(false, capability) - .expect_err("Remote account management must not use the controller account"); - assert_eq!( - account_error.kind, - TuiBackendErrorKind::Unsupported { - capability: capability.to_string() - } - ); - assert!(account_error.message.contains("does not fall back")); - } - - let worktree_error = require_local_management_scope(false, WORKTREES_CAPABILITY) - .expect_err("Remote worktree management must not use the controller worktree owner"); - assert_eq!( - worktree_error.kind, - TuiBackendErrorKind::Unsupported { - capability: WORKTREES_CAPABILITY.to_string() - } - ); - assert!(worktree_error.message.contains("does not fall back")); - } - - fn agent_event(text: &str) -> RuntimeIpcClientEvent { - RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::Agent { - session_id: "session-1".to_string(), - envelope: AgenticEventEnvelope::new( - AgenticEvent::TextChunk { - session_id: "session-1".to_string(), - turn_id: "turn-1".to_string(), - round_id: "round-1".to_string(), - attempt_id: None, - attempt_index: None, - text: text.to_string(), - }, - AgenticEventPriority::Normal, - ), - }) - } - - #[tokio::test] - async fn event_bridge_preserves_monotonic_cursors_and_projects_invalidation() { - let (source, source_rx) = broadcast::channel(8); - let (output, mut output_rx) = broadcast::channel(8); - spawn_event_bridge(source_rx, output, "connection-1".to_string()); - - source.send(agent_event("one")).expect("first event"); - source.send(agent_event("two")).expect("second event"); - source - .send(RuntimeIpcClientEvent::Runtime( - RuntimeIpcEvent::StreamInvalidated { - reason: RuntimeIpcStreamInvalidationReason::Lagged, - }, - )) - .expect("invalidation"); - - for expected in [1, 2] { - let AppServerEvent::Agent(notification) = output_rx.recv().await.expect("agent event") - else { - panic!("expected agent event"); - }; - assert_eq!(notification.cursor.connection_id, "connection-1"); - assert_eq!(notification.cursor.stream, EventStream::Agent); - assert_eq!(notification.cursor.sequence, expected); - } - let AppServerEvent::StreamState(notification) = - output_rx.recv().await.expect("stream invalidation") - else { - panic!("expected stream state"); - }; - assert_eq!(notification.cursor.sequence, 3); - assert_eq!(notification.state, EventStreamState::Invalidated); - assert_eq!(notification.resync.method, "session/sync"); - assert!(notification.resync.snapshot_available); - } - - #[tokio::test] - async fn event_bridge_projects_disconnect_as_connection_closed() { - let (source, source_rx) = broadcast::channel(2); - let (output, mut output_rx) = broadcast::channel(2); - spawn_event_bridge(source_rx, output, "connection-2".to_string()); - - source - .send(RuntimeIpcClientEvent::Disconnected) - .expect("disconnect event"); - - assert!(matches!( - output_rx.recv().await.expect("connection closed"), - AppServerEvent::ConnectionClosed - )); - } -} diff --git a/src/apps/cli/src/tui_backend.rs b/src/apps/cli/src/tui_backend.rs deleted file mode 100644 index 36f7ab618a..0000000000 --- a/src/apps/cli/src/tui_backend.rs +++ /dev/null @@ -1,992 +0,0 @@ -//! CLI-local App Server boundary for the interactive TUI. - -use async_trait::async_trait; -use bitfun_app_server_client::{AppServerClient, AppServerEvent, ClientError, ProtocolError}; -use bitfun_app_server_protocol::account::*; -use bitfun_app_server_protocol::agent::*; -use bitfun_app_server_protocol::app::{HealthResponse, InitializeRequest, InitializeResponse}; -use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; -use bitfun_app_server_protocol::external_source::*; -use bitfun_app_server_protocol::hook::*; -use bitfun_app_server_protocol::mcp::*; -use bitfun_app_server_protocol::model::*; -use bitfun_app_server_protocol::session::*; -use bitfun_app_server_protocol::skill::*; -use bitfun_app_server_protocol::subagent::*; -use bitfun_app_server_protocol::workspace::*; -use bitfun_app_server_protocol::worktree::*; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub(crate) enum TuiEffectRoute { - Local, - AppServer, - HostCapability, -} - -pub(crate) trait TuiEffect { - fn route(&self) -> TuiEffectRoute; -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct TuiBackendError { - pub message: String, - pub outcome_unknown: bool, - pub kind: TuiBackendErrorKind, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum TuiBackendErrorKind { - Backend, - Unsupported { capability: String }, -} - -impl std::fmt::Display for TuiBackendError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(&self.message) - } -} - -impl std::error::Error for TuiBackendError {} - -#[async_trait] -#[allow(dead_code)] -pub(crate) trait TuiBackend: Send + Sync { - async fn initialize( - &self, - request: InitializeRequest, - ) -> Result; - - async fn health(&self) -> Result; - - fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver; - - async fn model_catalog(&self) -> Result; - async fn account_snapshot( - &self, - request: AccountSnapshotRequest, - ) -> Result; - async fn account_login( - &self, - request: AccountLoginRequest, - ) -> Result; - async fn account_finalize_login( - &self, - request: AccountFinalizeLoginRequest, - ) -> Result; - async fn account_logout( - &self, - request: AccountLogoutRequest, - ) -> Result; - async fn settings_sync_start( - &self, - request: SettingsSyncStartRequest, - ) -> Result; - async fn settings_sync_snapshot( - &self, - request: SettingsSyncSnapshotRequest, - ) -> Result; - async fn settings_sync_cancel( - &self, - request: SettingsSyncCancelRequest, - ) -> Result; - async fn settings_sync_local_changed( - &self, - request: SettingsSyncLocalChangedRequest, - ) -> Result; - async fn worktree_repository_status( - &self, - request: WorktreeRepositoryStatusRequest, - ) -> Result; - async fn worktree_bind_session( - &self, - request: WorktreeBindSessionRequest, - ) -> Result; - async fn worktree_release_session( - &self, - request: WorktreeReleaseSessionRequest, - ) -> Result; - - async fn list_sessions( - &self, - request: ListSessionsRequest, - ) -> Result; - async fn sync_session( - &self, - request: SyncSessionRequest, - ) -> Result; - async fn create_session( - &self, - request: CreateSessionRequest, - ) -> Result; - async fn delete_session( - &self, - request: DeleteSessionRequest, - ) -> Result; - async fn rename_session( - &self, - request: RenameSessionRequest, - ) -> Result; - async fn submit_dialog_turn( - &self, - request: SubmitDialogTurnRequest, - ) -> Result; - async fn cancel_turn( - &self, - request: CancelTurnRequest, - ) -> Result; - async fn steer_turn( - &self, - request: SteerTurnRequest, - ) -> Result; - async fn run_user_shell_command( - &self, - request: RunUserShellCommandRequest, - ) -> Result; - async fn submit_user_answers( - &self, - request: SubmitUserAnswersRequest, - ) -> Result; - async fn respond_permission( - &self, - request: RespondPermissionRequest, - ) -> Result; - async fn pending_permissions(&self) -> Result; - async fn compact_session( - &self, - request: CompactSessionRequest, - ) -> Result; - async fn undo_session( - &self, - request: UndoSessionRequest, - ) -> Result; - async fn redo_session( - &self, - request: RedoSessionRequest, - ) -> Result; - async fn reload_context( - &self, - request: ReloadContextRequest, - ) -> Result; - async fn session_usage( - &self, - request: SessionUsageRequest, - ) -> Result; - async fn wait_for_settlement( - &self, - request: WaitForSettlementRequest, - ) -> Result; - async fn workspace_diff(&self) -> Result; - async fn search_workspace_references( - &self, - request: SearchWorkspaceReferencesRequest, - ) -> Result; - async fn message_references( - &self, - request: MessageReferencesRequest, - ) -> Result; - async fn session_lineage( - &self, - request: SessionLineageRequest, - ) -> Result; - async fn inspect_lineage( - &self, - request: InspectLineageRequest, - ) -> Result; - async fn cancel_lineage( - &self, - request: CancelLineageRequest, - ) -> Result; - async fn fork_session( - &self, - request: ForkSessionRequest, - ) -> Result; - async fn fork_session_before_turn( - &self, - request: ForkSessionBeforeTurnRequest, - ) -> Result; - async fn update_session_model( - &self, - request: UpdateSessionModelRequest, - ) -> Result; - async fn update_session_mode( - &self, - request: UpdateSessionModeRequest, - ) -> Result; - - async fn list_agent_modes( - &self, - request: ListAgentModesRequest, - ) -> Result; - async fn list_models(&self) -> Result; - async fn get_model( - &self, - request: GetModelRequest, - ) -> Result; - async fn add_model( - &self, - request: AddModelRequest, - ) -> Result; - async fn update_model( - &self, - request: UpdateModelRequest, - ) -> Result; - async fn delete_model( - &self, - request: DeleteModelRequest, - ) -> Result; - async fn set_model_default( - &self, - request: SetModelDefaultRequest, - ) -> Result; - async fn list_skills( - &self, - request: ListSkillsRequest, - ) -> Result; - async fn set_skill_enabled( - &self, - request: SetSkillEnabledRequest, - ) -> Result; - async fn list_subagents( - &self, - request: ListSubagentsRequest, - ) -> Result; - async fn set_subagent_enabled( - &self, - request: SetSubagentEnabledRequest, - ) -> Result; - async fn list_mcp_servers( - &self, - request: ListMcpServersRequest, - ) -> Result; - async fn toggle_mcp_server( - &self, - request: ToggleMcpServerRequest, - ) -> Result; - async fn add_mcp_server( - &self, - request: AddMcpServerRequest, - ) -> Result; - async fn delete_mcp_server( - &self, - request: DeleteMcpServerRequest, - ) -> Result; - async fn external_mcp_decision( - &self, - request: ExternalMcpDecisionRequest, - ) -> Result; - async fn mcp_conflict_choice( - &self, - request: McpConflictChoiceRequest, - ) -> Result; - async fn external_source_snapshot( - &self, - request: ExternalSourceSnapshotRequest, - ) -> Result; - async fn external_source_control( - &self, - request: ExternalSourceControlRequest, - ) -> Result; - async fn external_source_review( - &self, - request: ExternalSourceReviewRequest, - ) -> Result; - async fn set_native_command_choice( - &self, - request: SetNativeCommandChoiceRequest, - ) -> Result; - async fn expand_external_command( - &self, - request: ExpandExternalCommandRequest, - ) -> Result; - async fn native_hook_overview( - &self, - request: NativeHookOverviewRequest, - ) -> Result; - async fn external_hook_snapshot( - &self, - request: ExternalHookSnapshotRequest, - ) -> Result; - async fn external_hook_plan( - &self, - request: ExternalHookPlanRequest, - ) -> Result; - async fn external_hook_apply( - &self, - request: ExternalHookApplyRequest, - ) -> Result; - async fn external_hook_mutate( - &self, - request: ExternalHookMutationRequest, - ) -> Result; -} - -pub(crate) struct AppServerTuiBackend { - client: AppServerClient, -} - -impl AppServerTuiBackend { - pub(crate) fn new(client: AppServerClient) -> Self { - Self { client } - } -} - -#[async_trait] -impl TuiBackend for AppServerTuiBackend { - async fn initialize( - &self, - request: InitializeRequest, - ) -> Result { - map(self.client.initialize(request).await) - } - - async fn health(&self) -> Result { - map(self.client.health().await) - } - - async fn model_catalog(&self) -> Result { - map(self.client.tui_model_catalog().await) - } - - fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver { - self.client.subscribe_events() - } - - async fn account_snapshot( - &self, - request: AccountSnapshotRequest, - ) -> Result { - map(self.client.account_snapshot(request).await) - } - - async fn account_login( - &self, - request: AccountLoginRequest, - ) -> Result { - map_client(self.client.account_login(request).await) - } - - async fn account_finalize_login( - &self, - request: AccountFinalizeLoginRequest, - ) -> Result { - map_client(self.client.account_finalize_login(request).await) - } - - async fn account_logout( - &self, - request: AccountLogoutRequest, - ) -> Result { - map_client(self.client.account_logout(request).await) - } - - async fn settings_sync_start( - &self, - request: SettingsSyncStartRequest, - ) -> Result { - map_client(self.client.settings_sync_start(request).await) - } - - async fn settings_sync_snapshot( - &self, - request: SettingsSyncSnapshotRequest, - ) -> Result { - map(self.client.settings_sync_snapshot(request).await) - } - - async fn settings_sync_cancel( - &self, - request: SettingsSyncCancelRequest, - ) -> Result { - map_client(self.client.settings_sync_cancel(request).await) - } - - async fn settings_sync_local_changed( - &self, - request: SettingsSyncLocalChangedRequest, - ) -> Result { - map_client(self.client.settings_sync_local_changed(request).await) - } - - async fn worktree_repository_status( - &self, - request: WorktreeRepositoryStatusRequest, - ) -> Result { - map(self.client.worktree_repository_status(request).await) - } - - async fn worktree_bind_session( - &self, - request: WorktreeBindSessionRequest, - ) -> Result { - map_client(self.client.worktree_bind_session(request).await) - } - - async fn worktree_release_session( - &self, - request: WorktreeReleaseSessionRequest, - ) -> Result { - map_client(self.client.worktree_release_session(request).await) - } - - async fn list_sessions( - &self, - request: ListSessionsRequest, - ) -> Result { - map(self.client.list_sessions(request).await) - } - - async fn sync_session( - &self, - request: SyncSessionRequest, - ) -> Result { - map(self.client.sync_session(request).await) - } - - async fn create_session( - &self, - request: CreateSessionRequest, - ) -> Result { - map_client(self.client.create_session(request).await) - } - - async fn delete_session( - &self, - request: DeleteSessionRequest, - ) -> Result { - map_client(self.client.delete_session(request).await) - } - - async fn rename_session( - &self, - request: RenameSessionRequest, - ) -> Result { - map_client(self.client.rename_session(request).await) - } - - async fn submit_dialog_turn( - &self, - request: SubmitDialogTurnRequest, - ) -> Result { - map_client(self.client.submit_dialog_turn(request).await) - } - - async fn cancel_turn( - &self, - request: CancelTurnRequest, - ) -> Result { - map_client(self.client.cancel_turn(request).await) - } - - async fn steer_turn( - &self, - request: SteerTurnRequest, - ) -> Result { - map_client(self.client.steer_turn(request).await) - } - - async fn run_user_shell_command( - &self, - request: RunUserShellCommandRequest, - ) -> Result { - map_client(self.client.run_user_shell_command(request).await) - } - - async fn submit_user_answers( - &self, - request: SubmitUserAnswersRequest, - ) -> Result { - map_client(self.client.submit_user_answers(request).await) - } - - async fn respond_permission( - &self, - request: RespondPermissionRequest, - ) -> Result { - map_client(self.client.respond_permission(request).await) - } - - async fn pending_permissions(&self) -> Result { - map(self.client.pending_permissions().await) - } - - async fn compact_session( - &self, - request: CompactSessionRequest, - ) -> Result { - map_client(self.client.compact_session(request).await) - } - - async fn undo_session( - &self, - request: UndoSessionRequest, - ) -> Result { - map_client(self.client.undo_session(request).await) - } - - async fn redo_session( - &self, - request: RedoSessionRequest, - ) -> Result { - map_client(self.client.redo_session(request).await) - } - - async fn reload_context( - &self, - request: ReloadContextRequest, - ) -> Result { - map_client(self.client.reload_context(request).await) - } - - async fn session_usage( - &self, - request: SessionUsageRequest, - ) -> Result { - map(self.client.session_usage(request).await) - } - - async fn wait_for_settlement( - &self, - request: WaitForSettlementRequest, - ) -> Result { - map(self.client.wait_for_settlement(request).await) - } - - async fn workspace_diff(&self) -> Result { - map(self.client.workspace_diff().await) - } - - async fn search_workspace_references( - &self, - request: SearchWorkspaceReferencesRequest, - ) -> Result { - map(self.client.search_workspace_references(request).await) - } - - async fn message_references( - &self, - request: MessageReferencesRequest, - ) -> Result { - map(self.client.message_references(request).await) - } - - async fn session_lineage( - &self, - request: SessionLineageRequest, - ) -> Result { - map(self.client.session_lineage(request).await) - } - - async fn inspect_lineage( - &self, - request: InspectLineageRequest, - ) -> Result { - map(self.client.inspect_lineage(request).await) - } - - async fn cancel_lineage( - &self, - request: CancelLineageRequest, - ) -> Result { - map_client(self.client.cancel_lineage(request).await) - } - - async fn fork_session( - &self, - request: ForkSessionRequest, - ) -> Result { - map_client(self.client.fork_session(request).await) - } - - async fn fork_session_before_turn( - &self, - request: ForkSessionBeforeTurnRequest, - ) -> Result { - map_client(self.client.fork_session_before_turn(request).await) - } - - async fn update_session_model( - &self, - request: UpdateSessionModelRequest, - ) -> Result { - map_client(self.client.update_session_model(request).await) - } - - async fn update_session_mode( - &self, - request: UpdateSessionModeRequest, - ) -> Result { - map_client(self.client.update_session_mode(request).await) - } - - async fn list_agent_modes( - &self, - request: ListAgentModesRequest, - ) -> Result { - map(self.client.list_agent_modes(request).await) - } - - async fn list_models(&self) -> Result { - map(self.client.list_models().await) - } - - async fn get_model( - &self, - request: GetModelRequest, - ) -> Result { - map(self.client.get_model(request).await) - } - - async fn add_model( - &self, - request: AddModelRequest, - ) -> Result { - map_client(self.client.add_model(request).await) - } - - async fn update_model( - &self, - request: UpdateModelRequest, - ) -> Result { - map_client(self.client.update_model(request).await) - } - - async fn delete_model( - &self, - request: DeleteModelRequest, - ) -> Result { - map_client(self.client.delete_model(request).await) - } - - async fn set_model_default( - &self, - request: SetModelDefaultRequest, - ) -> Result { - map_client(self.client.set_model_default(request).await) - } - - async fn list_skills( - &self, - request: ListSkillsRequest, - ) -> Result { - map(self.client.list_skills(request).await) - } - - async fn set_skill_enabled( - &self, - request: SetSkillEnabledRequest, - ) -> Result { - map_client(self.client.set_skill_enabled(request).await) - } - - async fn list_subagents( - &self, - request: ListSubagentsRequest, - ) -> Result { - map(self.client.list_subagents(request).await) - } - - async fn set_subagent_enabled( - &self, - request: SetSubagentEnabledRequest, - ) -> Result { - map_client(self.client.set_subagent_enabled(request).await) - } - - async fn list_mcp_servers( - &self, - request: ListMcpServersRequest, - ) -> Result { - map(self.client.list_mcp_servers(request).await) - } - - async fn toggle_mcp_server( - &self, - request: ToggleMcpServerRequest, - ) -> Result { - map_client(self.client.toggle_mcp_server(request).await) - } - - async fn add_mcp_server( - &self, - request: AddMcpServerRequest, - ) -> Result { - map_client(self.client.add_mcp_server(request).await) - } - - async fn delete_mcp_server( - &self, - request: DeleteMcpServerRequest, - ) -> Result { - map_client(self.client.delete_mcp_server(request).await) - } - - async fn external_mcp_decision( - &self, - request: ExternalMcpDecisionRequest, - ) -> Result { - map_client(self.client.external_mcp_decision(request).await) - } - - async fn mcp_conflict_choice( - &self, - request: McpConflictChoiceRequest, - ) -> Result { - map_client(self.client.mcp_conflict_choice(request).await) - } - - async fn external_source_snapshot( - &self, - request: ExternalSourceSnapshotRequest, - ) -> Result { - map(self.client.external_source_snapshot(request).await) - } - - async fn external_source_control( - &self, - request: ExternalSourceControlRequest, - ) -> Result { - map_client(self.client.external_source_control(request).await) - } - - async fn external_source_review( - &self, - request: ExternalSourceReviewRequest, - ) -> Result { - map_client(self.client.external_source_review(request).await) - } - - async fn set_native_command_choice( - &self, - request: SetNativeCommandChoiceRequest, - ) -> Result { - map_client(self.client.set_native_command_choice(request).await) - } - - async fn expand_external_command( - &self, - request: ExpandExternalCommandRequest, - ) -> Result { - map_client(self.client.expand_external_command(request).await) - } - - async fn native_hook_overview( - &self, - request: NativeHookOverviewRequest, - ) -> Result { - map(self.client.native_hook_overview(request).await) - } - - async fn external_hook_snapshot( - &self, - request: ExternalHookSnapshotRequest, - ) -> Result { - map(self.client.external_hook_snapshot(request).await) - } - - async fn external_hook_plan( - &self, - request: ExternalHookPlanRequest, - ) -> Result { - map(self.client.external_hook_plan(request).await) - } - - async fn external_hook_apply( - &self, - request: ExternalHookApplyRequest, - ) -> Result { - map_client(self.client.external_hook_apply(request).await) - } - - async fn external_hook_mutate( - &self, - request: ExternalHookMutationRequest, - ) -> Result { - map_client(self.client.external_hook_mutate(request).await) - } -} - -fn map(result: Result) -> Result { - result.map_err(map_protocol_error) -} - -fn map_client(result: Result) -> Result { - result.map_err(|error| match error { - ClientError::Protocol(error) => map_protocol_error(error), - ClientError::Timeout(data) => backend_error_from_data( - "App Server request timed out with unknown outcome".to_string(), - data, - ), - }) -} - -fn map_protocol_error(error: ProtocolError) -> TuiBackendError { - let message = error.to_string(); - if i32::from(error.code) == -32601 { - return TuiBackendError { - message, - outcome_unknown: false, - kind: TuiBackendErrorKind::Unsupported { - capability: "appServer.method".to_string(), - }, - }; - } - if let Some(value) = error.data { - if let Ok(external) = serde_json::from_value::(value.clone()) { - let kind = match external.app.capability { - Some(capability) - if matches!(external.app.kind, AppServerErrorKind::Unsupported) => - { - TuiBackendErrorKind::Unsupported { capability } - } - _ => TuiBackendErrorKind::Backend, - }; - return TuiBackendError { - message: external.error.encode(), - outcome_unknown: external.app.outcome_unknown, - kind, - }; - } - if let Ok(worktree) = serde_json::from_value::(value.clone()) { - let kind = if matches!(worktree.app.kind, AppServerErrorKind::Unsupported) { - worktree - .app - .capability - .clone() - .map(|capability| TuiBackendErrorKind::Unsupported { capability }) - .unwrap_or(TuiBackendErrorKind::Backend) - } else { - TuiBackendErrorKind::Backend - }; - return TuiBackendError { - message: worktree.error.encode(), - outcome_unknown: worktree.app.outcome_unknown, - kind, - }; - } - if let Ok(data) = serde_json::from_value::(value) { - return backend_error_from_data(message, data); - } - } - TuiBackendError { - message, - outcome_unknown: false, - kind: TuiBackendErrorKind::Backend, - } -} - -fn backend_error_from_data(message: String, data: AppServerErrorData) -> TuiBackendError { - let kind = match (data.kind, data.capability) { - (AppServerErrorKind::Unsupported, Some(capability)) => { - TuiBackendErrorKind::Unsupported { capability } - } - _ => TuiBackendErrorKind::Backend, - }; - TuiBackendError { - message, - outcome_unknown: data.outcome_unknown, - kind, - } -} - -#[cfg(test)] -mod tests { - use super::{map_protocol_error, TuiBackendErrorKind, TuiEffect, TuiEffectRoute}; - use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; - use bitfun_app_server_protocol::external_source::ExternalSourceErrorData; - use bitfun_product_domains::external_sources::{ - ExternalSourceOperationError, ExternalSourceOperationErrorCode, - }; - - struct LocalEffect; - - impl TuiEffect for LocalEffect { - fn route(&self) -> TuiEffectRoute { - TuiEffectRoute::Local - } - } - - #[test] - fn effect_routes_are_explicit() { - assert_eq!(LocalEffect.route(), TuiEffectRoute::Local); - assert_ne!(TuiEffectRoute::AppServer, TuiEffectRoute::HostCapability); - } - - #[test] - fn method_not_found_is_treated_as_an_unsupported_host_method() { - let mapped = - map_protocol_error(bitfun_app_server_client::ProtocolError::method_not_found()); - assert!(matches!( - mapped.kind, - TuiBackendErrorKind::Unsupported { .. } - )); - assert!(!mapped.outcome_unknown); - } - - #[test] - fn protocol_unsupported_preserves_the_capability_id() { - let error = bitfun_app_server_client::ProtocolError::new( - AppServerErrorKind::Unsupported.json_rpc_code() as i32, - "not supported", - ) - .data( - serde_json::to_value(AppServerErrorData { - kind: AppServerErrorKind::Unsupported, - retryable: false, - outcome_unknown: false, - capability: Some("tui.models".to_string()), - request_id: None, - }) - .expect("serialize error data"), - ); - - let mapped = map_protocol_error(error); - assert_eq!( - mapped.kind, - TuiBackendErrorKind::Unsupported { - capability: "tui.models".to_string() - } - ); - assert!(!mapped.outcome_unknown); - } - - #[test] - fn external_source_protocol_error_preserves_domain_contract() { - let domain = ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::StaleRevision, - "The external source catalog changed", - false, - ) - .with_correlation_id("external-source-ref-5") - .with_default_recovery_actions(); - let error = bitfun_app_server_client::ProtocolError::new( - AppServerErrorKind::StaleRevision.json_rpc_code() as i32, - domain.detail.clone(), - ) - .data( - serde_json::to_value(ExternalSourceErrorData { - app: AppServerErrorData { - kind: AppServerErrorKind::StaleRevision, - retryable: domain.retryable, - outcome_unknown: false, - capability: Some("tui.externalSources".to_string()), - request_id: domain.correlation_id.clone(), - }, - error: domain.clone(), - }) - .expect("serialize external source error data"), - ); - - let mapped = map_protocol_error(error); - assert_eq!(mapped.kind, TuiBackendErrorKind::Backend); - assert!(!mapped.outcome_unknown); - let decoded = ExternalSourceOperationError::decode(&mapped.message) - .expect("decode mapped external source error"); - assert_eq!( - decoded.code, - ExternalSourceOperationErrorCode::StaleRevision - ); - assert_eq!(decoded.correlation_id, domain.correlation_id); - assert_eq!(decoded.recovery_actions, domain.recovery_actions); - } -} diff --git a/src/apps/cli/src/tui_management.rs b/src/apps/cli/src/tui_management.rs new file mode 100644 index 0000000000..12fb90a39a --- /dev/null +++ b/src/apps/cli/src/tui_management.rs @@ -0,0 +1,257 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use bitfun_core::service::remote_connect::account_runtime::{ + AccountManagementError, AccountManagementErrorKind, AccountRuntime, +}; +use bitfun_core::service::worktree::{ + WorktreeService, WorktreeSessionBindingRequest, WorktreeSessionBindingResult, +}; +use bitfun_product_domains::external_hook_import::{ + ExternalHookImportApplyRequestV1, ExternalHookImportApplyResultV1, + ExternalHookImportMutationRequestV1, ExternalHookImportPlanV1, ExternalHookImportSnapshotV1, +}; +use bitfun_product_domains::external_source_control::{ + ExternalSourceControlRequestV1, ExternalSourceSurfaceSnapshotV1, +}; +use bitfun_product_domains::external_sources::{ + ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + NativePromptCommandConflictSnapshot, NativePromptCommandDescriptor, + PromptCommandInvocationOutcome, PromptCommandShellReviewDecision, SourceKey, +}; +use bitfun_runtime_ports::AgentSessionWorkspaceBinding; + +mod account_worktree; +mod external_sources; +mod hooks; +mod mcp; +mod model_registry; + +pub(crate) use account_worktree::*; +pub(crate) use external_sources::*; +pub(crate) use hooks::*; +pub(crate) use mcp::*; +pub(crate) use model_registry::*; + +fn map_core_management_error(error: bitfun_core::BitFunError) -> ManagementError { + match error { + bitfun_core::BitFunError::Validation(message) => { + ManagementError::invalid_request(bounded_error(message)) + } + bitfun_core::BitFunError::NotFound(message) => { + ManagementError::not_found(bounded_error(message)) + } + error => ManagementError::internal(bounded_error(error.to_string())), + } +} + +const ACCOUNT_CAPABILITY: &str = "tui.account"; +const SETTINGS_SYNC_CAPABILITY: &str = "tui.settingsSync"; +const WORKTREES_CAPABILITY: &str = "tui.worktrees"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ManagementErrorKind { + Unsupported, + InvalidRequest, + NotFound, + Internal, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ManagementError { + pub kind: ManagementErrorKind, + pub message: String, +} + +pub(crate) type ManagementResult = Result; + +impl fmt::Display for ManagementError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for ManagementError {} + +impl ManagementError { + fn unsupported(message: impl Into) -> Self { + Self { + kind: ManagementErrorKind::Unsupported, + message: message.into(), + } + } + + fn invalid_request(message: impl Into) -> Self { + Self { + kind: ManagementErrorKind::InvalidRequest, + message: message.into(), + } + } + + fn not_found(message: impl Into) -> Self { + Self { + kind: ManagementErrorKind::NotFound, + message: message.into(), + } + } + + fn internal(message: impl Into) -> Self { + Self { + kind: ManagementErrorKind::Internal, + message: message.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ManagementScope { + pub workspace_path: PathBuf, + pub remote: bool, +} + +impl ManagementScope { + pub(crate) fn local(workspace_path: impl Into) -> Self { + Self { + workspace_path: workspace_path.into(), + remote: false, + } + } + + pub(crate) fn remote(workspace_path: impl Into) -> Self { + Self { + workspace_path: workspace_path.into(), + remote: true, + } + } + + pub(crate) fn local_workspace(&self, capability: &str) -> ManagementResult<&Path> { + if self.remote { + Err(ManagementError::unsupported(format!( + "{capability} is not supported for remote workspaces" + ))) + } else { + Ok(&self.workspace_path) + } + } +} + +fn validate_worktree_operation_id(operation_id: &str) -> ManagementResult<()> { + let valid = !operation_id.trim().is_empty() + && operation_id.len() <= 160 + && operation_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')); + valid + .then_some(()) + .ok_or_else(|| ManagementError::invalid_request("Worktree operation ID is invalid")) +} + +fn validate_external_operation_id(operation_id: &str) -> ManagementResult<()> { + if operation_id.is_empty() + || operation_id.len() > 160 + || operation_id.trim() != operation_id + || operation_id.chars().any(char::is_control) + { + Err(ManagementError::invalid_request( + "invalid external source operation id", + )) + } else { + Ok(()) + } +} + +fn bounded_error(message: String) -> String { + message + .chars() + .filter(|character| !character.is_control()) + .take(500) + .collect() +} + +fn map_account_management_error(error: AccountManagementError) -> ManagementError { + match error.kind { + AccountManagementErrorKind::InvalidRequest => { + ManagementError::invalid_request(error.message) + } + AccountManagementErrorKind::Internal => ManagementError::internal(error.message), + } +} + +pub(crate) fn map_external_error(error: ExternalSourceOperationError) -> ManagementError { + let encoded = error.encode(); + match error.code { + ExternalSourceOperationErrorCode::InvalidRequest => { + ManagementError::invalid_request(encoded) + } + ExternalSourceOperationErrorCode::NotFound => ManagementError::not_found(encoded), + ExternalSourceOperationErrorCode::HostCapabilityUnavailable + | ExternalSourceOperationErrorCode::Unsupported => ManagementError::unsupported(encoded), + _ => ManagementError::internal(encoded), + } +} + +fn map_external_string_error(error: String) -> ManagementError { + map_external_error( + bitfun_core::external_sources::sanitize_external_source_operation_error(error), + ) +} + +fn map_external_string_error_with_id(error: String, operation_id: &str) -> ManagementError { + let mut typed = bitfun_core::external_sources::sanitize_external_source_operation_error(error); + if typed.correlation_id.is_none() { + typed = typed.with_correlation_id(operation_id); + } else if typed.causation_id.is_none() { + typed = typed.with_causation_id(operation_id); + } + map_external_error(typed) +} + +/// Concrete management owners composed by the CLI surface. +/// +/// This is intentionally data, not a facade trait: each TUI operation still +/// delegates to the domain owner that defines its behavior and error contract. +pub(crate) struct TuiManagementOwners { + pub model: ModelProvider, + pub registry: RegistryProvider, + pub mcp: McpProvider, + pub account: AccountProvider, + pub settings_sync: SettingsSyncProvider, + pub worktree: WorktreeProvider, + pub native_hook: NativeHookProvider, + pub external_hook: ExternalHookProvider, + pub external_source: ExternalSourceProvider, + pub external_command: ExternalCommandProvider, +} + +impl TuiManagementOwners { + pub(crate) async fn load( + account: Option>, + embedded_runtime_owner: bool, + ) -> anyhow::Result { + let config = bitfun_core::service::config::get_global_config_service() + .await + .map_err(|error| anyhow::anyhow!("Failed to load CLI management config: {error}"))?; + Ok(Self { + model: ModelProvider::new(config), + registry: RegistryProvider::new(), + mcp: if embedded_runtime_owner { + McpProvider::new(bitfun_core::service::mcp::get_global_mcp_service()) + } else { + McpProvider::shared_runtime_unavailable() + }, + account: AccountProvider::new(account.clone()), + settings_sync: SettingsSyncProvider::new(account), + worktree: if embedded_runtime_owner { + WorktreeProvider::local() + } else { + WorktreeProvider::new(None) + }, + native_hook: NativeHookProvider::new(), + external_hook: ExternalHookProvider::new(), + external_source: ExternalSourceProvider::new(), + external_command: ExternalCommandProvider::new(), + }) + } +} diff --git a/src/apps/cli/src/tui_management/account_worktree.rs b/src/apps/cli/src/tui_management/account_worktree.rs new file mode 100644 index 0000000000..174d796c83 --- /dev/null +++ b/src/apps/cli/src/tui_management/account_worktree.rs @@ -0,0 +1,332 @@ +use super::*; + +pub(crate) use bitfun_core::service::remote_connect::account_runtime::{ + validate_management_operation_id, AccountDevice, AccountInfo, AccountSnapshot, + AccountSyncProgress, AccountSyncStatus, +}; +use bitfun_core::service::remote_connect::account_runtime::{ + AccountLoginNextStep, AccountLoginResult, +}; +pub(crate) type AccountSnapshotResponse = AccountSnapshot; +pub(crate) type SettingsSyncProgress = AccountSyncProgress; +pub(crate) type SettingsSyncStatus = AccountSyncStatus; + +#[derive(Debug, Clone)] +pub(crate) struct SettingsSyncResponse { + pub progress: AccountSyncProgress, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccountSyncChoice { + Local, + Cloud, +} +#[derive(Debug, Clone)] +pub(crate) struct AccountLoginView { + pub user_id: String, + pub relay_url: String, + pub has_cloud_settings: bool, + pub status_message: String, +} + +fn account_login_status_message(result: &AccountLoginResult) -> String { + match result.next_step() { + AccountLoginNextStep::ChooseSettingsSource => format!( + "Authenticated as user {} on {}. Choose cloud or local settings to finish login.", + result.user_id, result.relay_url + ), + AccountLoginNextStep::Ready if result.routing_connected => format!( + "Logged in as user {} on {}. Device routing connected.", + result.user_id, result.relay_url + ), + AccountLoginNextStep::Ready => { + format!( + "Logged in as user {} on {}.", + result.user_id, result.relay_url + ) + } + AccountLoginNextStep::RoutingUnavailable(error) => format!( + "Logged in as user {} on {}. Device routing failed: {}", + result.user_id, result.relay_url, error + ), + } +} + +pub(crate) struct AccountProvider { + account: Option>, +} + +impl AccountProvider { + pub(crate) fn new(account: Option>) -> Self { + Self { account } + } + + fn owner(&self) -> ManagementResult<&Arc> { + self.account.as_ref().ok_or_else(|| { + ManagementError::unsupported(format!("{ACCOUNT_CAPABILITY} is unavailable")) + }) + } + + pub(crate) async fn snapshot( + &self, + scope: &ManagementScope, + ) -> ManagementResult { + scope.local_workspace("Account management")?; + Ok(self.owner()?.snapshot().await) + } + + pub(crate) async fn login( + &self, + scope: &ManagementScope, + operation_id: &str, + relay_url: &str, + username: &str, + password: &str, + ) -> ManagementResult { + scope.local_workspace("Account management")?; + validate_management_operation_id(operation_id).map_err(map_account_management_error)?; + let result = self + .owner()? + .login_for_management(operation_id, relay_url, username, password) + .await + .map_err(map_account_management_error)?; + Ok(AccountLoginView { + user_id: result.user_id.clone(), + relay_url: result.relay_url.clone(), + has_cloud_settings: result.has_cloud_settings, + status_message: account_login_status_message(&result), + }) + } + + pub(crate) async fn finalize_login( + &self, + scope: &ManagementScope, + operation_id: String, + use_local_settings: bool, + workspace_path: PathBuf, + ) -> ManagementResult { + scope.local_workspace("Account management")?; + validate_management_operation_id(&operation_id).map_err(map_account_management_error)?; + self.owner()? + .finalize_login_for_management(operation_id, use_local_settings, workspace_path) + .await + .map_err(map_account_management_error) + } + + pub(crate) async fn logout( + &self, + scope: &ManagementScope, + operation_id: &str, + ) -> ManagementResult { + scope.local_workspace("Account management")?; + validate_management_operation_id(operation_id).map_err(map_account_management_error)?; + self.owner()? + .logout_for_management(operation_id.to_string()) + .await + .map_err(map_account_management_error) + } +} + +pub(crate) struct SettingsSyncProvider { + account: Option>, +} + +impl SettingsSyncProvider { + pub(crate) fn new(account: Option>) -> Self { + Self { account } + } + + fn owner(&self) -> ManagementResult<&Arc> { + self.account.as_ref().ok_or_else(|| { + ManagementError::unsupported(format!("{SETTINGS_SYNC_CAPABILITY} is unavailable")) + }) + } + + pub(crate) async fn start( + &self, + scope: &ManagementScope, + operation_id: String, + is_first_login: bool, + workspace_path: PathBuf, + ) -> ManagementResult { + scope.local_workspace("Settings sync management")?; + validate_management_operation_id(&operation_id).map_err(map_account_management_error)?; + self.owner()? + .start_settings_sync_for_management(operation_id, is_first_login, workspace_path) + .await + .map_err(map_account_management_error) + } + + pub(crate) async fn snapshot( + &self, + scope: &ManagementScope, + ) -> ManagementResult { + scope.local_workspace("Settings sync management")?; + Ok(self.owner()?.current_sync_progress().await) + } + + pub(crate) async fn cancel( + &self, + scope: &ManagementScope, + operation_id: &str, + ) -> ManagementResult { + scope.local_workspace("Settings sync management")?; + validate_management_operation_id(operation_id).map_err(map_account_management_error)?; + self.owner()? + .cancel_settings_sync_for_management(operation_id.to_string()) + .await + .map_err(map_account_management_error) + } + + pub(crate) async fn local_changed( + &self, + scope: &ManagementScope, + operation_id: &str, + ) -> ManagementResult { + scope.local_workspace("Settings sync management")?; + validate_management_operation_id(operation_id).map_err(map_account_management_error)?; + self.owner()? + .notify_local_settings_changed_for_management(operation_id) + .await + .map_err(map_account_management_error) + } +} + +pub(crate) struct WorktreeRepositoryStatus { + pub is_repository: bool, + pub current_branch: Option, +} + +#[async_trait] +pub(crate) trait WorktreeOwner: Send + Sync { + async fn bind_session( + &self, + request: WorktreeSessionBindingRequest, + ) -> Result; +} + +pub(crate) struct CoreWorktreeOwner; + +#[async_trait] +impl WorktreeOwner for CoreWorktreeOwner { + async fn bind_session( + &self, + request: WorktreeSessionBindingRequest, + ) -> Result { + WorktreeService::bind_session(request).await + } +} + +pub(crate) struct WorktreeProvider { + owner: Option>, +} +pub(crate) struct WorktreeBindingRequest { + pub scope: ManagementScope, + pub operation_id: String, + pub session_id: String, + pub project_workspace_path: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct WorktreeBindingView { + pub workspace_binding: AgentSessionWorkspaceBinding, + pub retained_worktree_path: Option, +} + +impl WorktreeProvider { + pub(crate) fn new(owner: Option>) -> Self { + Self { owner } + } + + pub(crate) fn local() -> Self { + Self::new(Some(Arc::new(CoreWorktreeOwner))) + } + + fn owner(&self) -> ManagementResult<&Arc> { + self.owner.as_ref().ok_or_else(|| { + ManagementError::unsupported(format!("{WORKTREES_CAPABILITY} is unavailable")) + }) + } + + pub(crate) async fn repository_status( + &self, + scope: &ManagementScope, + ) -> ManagementResult { + if scope.remote { + return Err(ManagementError::unsupported( + "Repository status is not supported for remote workspaces", + )); + } + self.owner()?; + let repository = match bitfun_core::service::git::GitService::resolve_worktree_repository( + &scope.workspace_path, + ) + .await + { + Ok(repository) => { + bitfun_core::service::git::GitService::get_repository_basic(repository.query_path) + .await + } + Err(error) => Err(error), + }; + Ok(match repository { + Ok(repository) => WorktreeRepositoryStatus { + is_repository: true, + current_branch: Some(repository.current_branch), + }, + Err(_) => WorktreeRepositoryStatus { + is_repository: false, + current_branch: None, + }, + }) + } + + pub(crate) async fn bind( + &self, + request: WorktreeBindingRequest, + ) -> ManagementResult { + self.transition(request, true).await + } + + pub(crate) async fn release( + &self, + request: WorktreeBindingRequest, + ) -> ManagementResult { + self.transition(request, false).await + } + + async fn transition( + &self, + request: WorktreeBindingRequest, + enabled: bool, + ) -> ManagementResult { + validate_worktree_operation_id(&request.operation_id)?; + if request.scope.remote { + return Err(ManagementError::unsupported( + "Managed worktrees are not supported for remote workspaces", + )); + } + let result = self + .owner()? + .bind_session(WorktreeSessionBindingRequest { + request_id: request.operation_id, + session_id: request.session_id, + project_workspace_path: request.project_workspace_path, + enabled, + }) + .await + .map_err(|error| ManagementError::internal(bounded_error(error.message)))?; + let execution_target = result.execution_target.clone(); + Ok(WorktreeBindingView { + workspace_binding: AgentSessionWorkspaceBinding { + workspace_id: result.workspace_id, + workspace_path: result.workspace_path, + project_workspace_path: Some(result.project_workspace_path), + execution_target: Some(execution_target), + remote_connection_id: None, + remote_ssh_host: None, + }, + retained_worktree_path: result.retained_worktree_path, + }) + } +} diff --git a/src/apps/cli/src/tui_management/external_sources.rs b/src/apps/cli/src/tui_management/external_sources.rs new file mode 100644 index 0000000000..1ec5acb86e --- /dev/null +++ b/src/apps/cli/src/tui_management/external_sources.rs @@ -0,0 +1,162 @@ +use super::*; + +#[derive(Debug, Clone)] +pub(crate) struct NativeCommandChoiceView { + pub conflicts: NativePromptCommandConflictSnapshot, + pub preferences: ExternalSourcePreferences, +} + +#[derive(Debug, Clone)] +pub(crate) struct ExternalSourcePreferences { + pub choices: BTreeMap, + pub lineage_current_keys: BTreeMap, + pub conflicted_candidate_ids: BTreeSet, +} + +#[derive(Debug, Clone)] +pub(crate) struct ExternalSourceSnapshotView { + pub surface: ExternalSourceSurfaceSnapshotV1, + pub preferences: ExternalSourcePreferences, +} + +pub(crate) use bitfun_core::external_sources::ExternalSourceReviewAction; + +pub(crate) struct ExternalSourceProvider; + +impl ExternalSourceProvider { + pub(crate) fn new() -> Self { + Self + } + + pub(crate) async fn snapshot( + &self, + scope: &ManagementScope, + force_refresh: bool, + ) -> ManagementResult { + let workspace = scope.local_workspace("External source management")?; + let surface = bitfun_core::external_sources::get_external_source_control_snapshot( + Some(workspace), + force_refresh, + ExternalSourceHostCapabilities::read_write(), + ) + .await + .map_err(map_external_error)?; + Ok(ExternalSourceSnapshotView { + surface, + preferences: external_source_preferences().await?, + }) + } + + pub(crate) async fn control( + &self, + scope: &ManagementScope, + request: ExternalSourceControlRequestV1, + ) -> ManagementResult { + let workspace = scope.local_workspace("External source management")?; + request + .validate() + .map_err(ManagementError::invalid_request)?; + bitfun_core::external_sources::apply_external_source_control_action( + Some(workspace), + request, + ) + .await + .map_err(map_external_error)?; + self.snapshot(scope, false).await + } + + pub(crate) async fn review( + &self, + scope: &ManagementScope, + operation_id: &str, + action: ExternalSourceReviewAction, + ) -> ManagementResult { + let workspace = scope.local_workspace("External source management")?; + validate_external_operation_id(operation_id)?; + let result = + bitfun_core::external_sources::review_external_source(Some(workspace), action).await; + result.map_err(|error| map_external_string_error_with_id(error, operation_id))?; + self.snapshot(scope, false).await + } +} + +async fn external_source_preferences() -> ManagementResult { + bitfun_core::external_sources::external_source_conflict_choices() + .await + .map( + |(choices, lineage_current_keys, conflicted_candidate_ids)| ExternalSourcePreferences { + choices, + lineage_current_keys, + conflicted_candidate_ids, + }, + ) + .map_err(map_external_string_error) +} + +#[derive(Clone)] +pub(crate) struct ExternalCommandExpansionRequest { + pub operation_id: String, + pub command_name: String, + pub arguments: String, + pub native_commands: Vec, + pub candidate_id: Option, + pub content_version: Option, + pub native_conflict_key: Option, + pub expected_preference_revision: Option, + pub shell_review_decision: Option, +} + +pub(crate) struct ExternalCommandProvider; + +impl ExternalCommandProvider { + pub(crate) fn new() -> Self { + Self + } + + pub(crate) async fn set_native_choice( + &self, + scope: &ManagementScope, + operation_id: &str, + native_commands: Vec, + selected_candidate_id: &str, + expected_preference_revision: u64, + ) -> ManagementResult<( + NativePromptCommandConflictSnapshot, + ExternalSourcePreferences, + )> { + let workspace = scope.local_workspace("External command management")?; + validate_external_operation_id(operation_id)?; + let conflicts = bitfun_core::external_sources::set_native_prompt_command_conflict_choice( + Some(workspace), + native_commands, + selected_candidate_id, + expected_preference_revision, + ) + .await + .map_err(|error| map_external_string_error_with_id(error, operation_id))?; + Ok((conflicts, external_source_preferences().await?)) + } + + pub(crate) async fn expand( + &self, + scope: &ManagementScope, + request: ExternalCommandExpansionRequest, + ) -> ManagementResult { + let workspace = scope.local_workspace("External command management")?; + validate_external_operation_id(&request.operation_id)?; + let operation_id = request.operation_id.clone(); + bitfun_core::external_sources::expand_external_prompt_command( + Some(workspace), + &request.command_name, + &request.arguments, + request.native_commands, + request.candidate_id.as_deref(), + request.content_version.as_deref(), + request.native_conflict_key.as_deref(), + request.expected_preference_revision, + request.shell_review_decision.as_ref(), + ) + .await + .map_err(|error| map_external_string_error_with_id(error, &operation_id)) + } +} diff --git a/src/apps/cli/src/tui_management/hooks.rs b/src/apps/cli/src/tui_management/hooks.rs new file mode 100644 index 0000000000..1d42e65b56 --- /dev/null +++ b/src/apps/cli/src/tui_management/hooks.rs @@ -0,0 +1,165 @@ +use super::*; + +#[derive(Debug, Clone)] +pub(crate) struct NativeHookHandlerSummary { + pub command_summary: String, + pub timeout_seconds: u64, + pub status_message: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct NativeHookRuleSummary { + pub event: String, + pub matcher: String, + pub matcher_is_valid: bool, + pub scope: String, + pub handlers: Vec, +} +#[derive(Debug, Clone)] +pub(crate) struct NativeHookFileSummary { + pub scope: String, + pub location: String, + pub exists: bool, + pub loaded: bool, +} + +#[derive(Clone)] +pub(crate) struct NativeHookOverviewView { + pub enabled: bool, + pub project_hooks_enabled: bool, + pub files: Vec, + pub rules: Vec, + pub total_handlers: usize, + pub issues: Vec, +} + +impl std::fmt::Debug for NativeHookOverviewView { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NativeHookOverviewView") + .field("enabled", &self.enabled) + .field("project_hooks_enabled", &self.project_hooks_enabled) + .field("file_count", &self.files.len()) + .field("rule_count", &self.rules.len()) + .field("total_handlers", &self.total_handlers) + .field("issue_count", &self.issues.len()) + .finish() + } +} + +pub(crate) struct NativeHookProvider; + +impl NativeHookProvider { + pub(crate) fn new() -> Self { + Self + } + + pub(crate) async fn overview( + &self, + scope: &ManagementScope, + ) -> ManagementResult { + let workspace = scope.local_workspace("Native hook management")?; + Ok(project_native_hook_overview( + bitfun_core::native_hooks::overview_for_management(workspace).await, + )) + } +} + +pub(crate) fn project_native_hook_overview( + overview: bitfun_core::native_hooks::NativeHookManagementOverview, +) -> NativeHookOverviewView { + NativeHookOverviewView { + enabled: overview.enabled, + project_hooks_enabled: overview.project_hooks_enabled, + files: overview + .files + .into_iter() + .map(|file| NativeHookFileSummary { + scope: file.scope, + location: file.location, + exists: file.exists, + loaded: file.loaded, + }) + .collect(), + rules: overview + .rules + .into_iter() + .map(|rule| NativeHookRuleSummary { + event: rule.event, + matcher: rule.matcher, + matcher_is_valid: rule.matcher_is_valid, + scope: rule.scope, + handlers: rule + .handlers + .into_iter() + .map(|handler| NativeHookHandlerSummary { + command_summary: handler.command_summary, + timeout_seconds: handler.timeout_seconds, + status_message: handler.status_message, + }) + .collect(), + }) + .collect(), + total_handlers: overview.total_handlers, + issues: overview.issues, + } +} + +pub(crate) struct ExternalHookProvider; + +impl ExternalHookProvider { + pub(crate) fn new() -> Self { + Self + } + + pub(crate) async fn snapshot( + &self, + scope: &ManagementScope, + refresh_updates: bool, + ) -> ManagementResult { + let workspace = scope.local_workspace("External hook management")?; + bitfun_core::external_hook_import::external_hook_import_snapshot( + Some(workspace), + refresh_updates, + ) + .await + .map_err(map_external_error) + } + + pub(crate) async fn plan( + &self, + scope: &ManagementScope, + source: SourceKey, + ) -> ManagementResult { + let workspace = scope.local_workspace("External hook management")?; + bitfun_core::external_hook_import::plan_external_hook_import(Some(workspace), source) + .await + .map_err(map_external_error) + } + + pub(crate) async fn apply( + &self, + scope: &ManagementScope, + operation_id: &str, + request: ExternalHookImportApplyRequestV1, + ) -> ManagementResult { + let workspace = scope.local_workspace("External hook management")?; + validate_external_operation_id(operation_id)?; + bitfun_core::external_hook_import::apply_external_hook_import(Some(workspace), request) + .await + .map_err(map_external_error) + } + + pub(crate) async fn mutate( + &self, + scope: &ManagementScope, + operation_id: &str, + request: ExternalHookImportMutationRequestV1, + ) -> ManagementResult { + let workspace = scope.local_workspace("External hook management")?; + validate_external_operation_id(operation_id)?; + bitfun_core::external_hook_import::mutate_external_hook_import(Some(workspace), request) + .await + .map_err(map_external_error) + } +} diff --git a/src/apps/cli/src/tui_management/mcp.rs b/src/apps/cli/src/tui_management/mcp.rs new file mode 100644 index 0000000000..0a62e7d549 --- /dev/null +++ b/src/apps/cli/src/tui_management/mcp.rs @@ -0,0 +1,146 @@ +use super::*; + +pub(crate) use bitfun_core::service::mcp::{ + McpManagementAction as McpServerAction, McpManagementList as McpServerList, + McpManagementMutation as McpServerMutation, McpManagementServer as McpServerSummary, + McpManagementTransport as McpTransport, +}; + +#[derive(Debug, Clone)] +pub(crate) struct ExternalMcpDecisionRequest { + pub candidate_id: String, + pub decision_key: String, + pub approved: bool, + pub expected_mcp_generation: u64, + pub expected_preference_revision: u64, +} + +#[derive(Debug, Clone)] +pub(crate) struct McpConflictChoiceRequest { + pub conflict_key: String, + pub candidate_id: String, + pub approve_external: bool, + pub expected_mcp_generation: u64, + pub expected_preference_revision: u64, +} + +pub(crate) struct McpProvider { + owner: Option, + unavailable_reason: &'static str, +} + +impl McpProvider { + pub(crate) fn new(service: Option>) -> Self { + Self { + owner: service.map(bitfun_core::service::mcp::McpManagementService::new), + unavailable_reason: "MCP management is unavailable", + } + } + + pub(crate) fn shared_runtime_unavailable() -> Self { + Self { + owner: None, + unavailable_reason: "MCP management is unavailable in Shared TUI; exit Shared clients, manage MCP in Embedded mode, and restart the Shared Runtime", + } + } + + fn owner(&self) -> ManagementResult<&bitfun_core::service::mcp::McpManagementService> { + self.owner + .as_ref() + .ok_or_else(|| ManagementError::unsupported(self.unavailable_reason)) + } + + pub(crate) async fn list(&self, scope: &ManagementScope) -> ManagementResult { + let workspace = scope.local_workspace("MCP management")?; + self.owner()? + .list(workspace) + .await + .map_err(map_mcp_management_error) + } + + pub(crate) async fn toggle( + &self, + scope: &ManagementScope, + server_id: &str, + ) -> ManagementResult<()> { + scope.local_workspace("MCP management")?; + self.owner()? + .toggle(server_id) + .await + .map_err(map_mcp_management_error) + } + + pub(crate) async fn add( + &self, + scope: &ManagementScope, + name: &str, + mutation: McpServerMutation, + ) -> ManagementResult<()> { + scope.local_workspace("MCP management")?; + self.owner()? + .add(name, mutation) + .await + .map_err(map_mcp_management_error) + } + + pub(crate) async fn delete( + &self, + scope: &ManagementScope, + server_id: &str, + ) -> ManagementResult<()> { + scope.local_workspace("MCP management")?; + self.owner()? + .delete(server_id) + .await + .map_err(map_mcp_management_error) + } + + pub(crate) async fn decide_external( + &self, + scope: &ManagementScope, + request: ExternalMcpDecisionRequest, + ) -> ManagementResult<()> { + let workspace = scope.local_workspace("MCP management")?; + bitfun_core::external_sources::set_external_mcp_server_decision( + Some(workspace), + &request.candidate_id, + &request.decision_key, + request.approved, + request.expected_mcp_generation, + request.expected_preference_revision, + ) + .await + .map(|_| ()) + .map_err(map_external_string_error) + } + + pub(crate) async fn choose_conflict( + &self, + scope: &ManagementScope, + request: McpConflictChoiceRequest, + ) -> ManagementResult<()> { + let workspace = scope.local_workspace("MCP management")?; + bitfun_core::external_sources::choose_external_mcp_conflict( + Some(workspace), + &request.conflict_key, + &request.candidate_id, + request.approve_external, + request.expected_mcp_generation, + request.expected_preference_revision, + ) + .await + .map(|_| ()) + .map_err(map_external_string_error) + } +} + +fn map_mcp_management_error( + error: bitfun_core::service::mcp::McpManagementError, +) -> ManagementError { + use bitfun_core::service::mcp::McpManagementErrorKind as Kind; + match error.kind { + Kind::InvalidRequest => ManagementError::invalid_request(error.message), + Kind::Unavailable => ManagementError::unsupported(error.message), + Kind::Internal => ManagementError::internal(error.message), + } +} diff --git a/src/apps/cli/src/tui_management/model_registry.rs b/src/apps/cli/src/tui_management/model_registry.rs new file mode 100644 index 0000000000..f98e08e98e --- /dev/null +++ b/src/apps/cli/src/tui_management/model_registry.rs @@ -0,0 +1,412 @@ +use super::*; + +pub(crate) use bitfun_core::service::config::{ + AIModelManagementCatalog as ModelCatalog, AIModelManagementProjection as ModelEditProjection, + AIModelManagementSummary as ModelSummary, +}; + +#[derive(Clone)] +pub(crate) enum ModelSecretUpdate { + Preserve, + Replace(String), + Clear, +} + +impl std::fmt::Debug for ModelSecretUpdate { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Preserve => "Preserve", + Self::Replace(_) => "Replace()", + Self::Clear => "Clear", + }) + } +} + +#[derive(Clone)] +pub(crate) struct ModelMutation { + pub id: String, + pub name: String, + pub provider: String, + pub model_name: String, + pub base_url: String, + pub api_key: Option, + pub custom_headers: Option, + pub custom_request_body: Option, + pub context_window: Option, + pub max_tokens: Option, + pub enabled: bool, + pub reasoning: Option, + pub inline_think_in_text: bool, + pub skip_ssl_verify: bool, + pub custom_headers_mode: Option, +} + +impl std::fmt::Debug for ModelMutation { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ModelMutation") + .field("id", &self.id) + .field("name", &self.name) + .field("provider", &self.provider) + .field("model_name", &self.model_name) + .field("base_url", &self.base_url) + .field("api_key", &self.api_key) + .field("custom_headers", &self.custom_headers) + .field("custom_request_body", &self.custom_request_body) + .field("context_window", &self.context_window) + .field("max_tokens", &self.max_tokens) + .field("enabled", &self.enabled) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ModelDefaultSlot { + Mode, +} + +#[derive(Debug, Clone)] +pub(crate) struct AddModelRequest { + pub model: ModelMutation, + pub make_primary_if_empty: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct UpdateModelRequest { + pub model_id: String, + pub model: ModelMutation, +} + +#[derive(Debug, Clone)] +pub(crate) struct SetModelDefaultRequest { + pub slot: ModelDefaultSlot, + pub model_id: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct SkillSummary { + pub key: String, + pub name: String, + pub description: String, + pub level: String, + pub source_slot: Option, + pub source_label: Option, + pub enabled: bool, + pub selected_for_runtime: bool, + pub default_enabled: bool, + pub is_shadowed: bool, + pub shadowed_by_key: Option, + pub argument_hint: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct SubagentSummary { + pub key: String, + pub id: String, + pub name: String, + pub description: String, + pub source: String, + pub enabled: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct TuiModelCatalog { + pub provider_catalog: bitfun_core_types::ProviderCatalog, +} + +#[derive(Debug, Clone)] +pub(crate) struct ListSkillsResponse { + pub skills: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct ListSubagentsResponse { + pub subagents: Vec, + pub has_external: bool, +} + +pub(crate) struct ModelProvider { + config: Arc, +} + +impl ModelProvider { + pub(crate) fn new(config: Arc) -> Self { + Self { config } + } + + pub(crate) async fn catalog( + &self, + scope: &ManagementScope, + ) -> ManagementResult { + scope.local_workspace("Model management")?; + let catalog = bitfun_core::get_ai_model_catalog() + .await + .map_err(ManagementError::internal)?; + Ok(TuiModelCatalog { + provider_catalog: catalog.provider_catalog, + }) + } + + pub(crate) async fn list(&self, scope: &ManagementScope) -> ManagementResult { + scope.local_workspace("Model management")?; + self.config + .list_ai_models_for_management() + .await + .map_err(map_core_management_error) + } + + pub(crate) async fn get( + &self, + scope: &ManagementScope, + model_id: &str, + ) -> ManagementResult { + scope.local_workspace("Model management")?; + self.config + .get_ai_model_for_management(model_id) + .await + .map_err(map_core_management_error) + } + + pub(crate) async fn add( + &self, + scope: &ManagementScope, + mutation: ModelMutation, + make_primary_if_empty: bool, + ) -> ManagementResult<()> { + scope.local_workspace("Model management")?; + self.config + .add_ai_model_for_management(model_owner_mutation(mutation)?, make_primary_if_empty) + .await + .map_err(map_core_management_error) + } + + pub(crate) async fn update( + &self, + scope: &ManagementScope, + model_id: &str, + mutation: ModelMutation, + ) -> ManagementResult<()> { + scope.local_workspace("Model management")?; + self.config + .update_ai_model_for_management(model_id, model_owner_mutation(mutation)?) + .await + .map_err(map_core_management_error) + } + + pub(crate) async fn set_default( + &self, + scope: &ManagementScope, + slot: ModelDefaultSlot, + model_id: Option, + ) -> ManagementResult<()> { + scope.local_workspace("Model management")?; + let slot = match slot { + ModelDefaultSlot::Mode => bitfun_core::service::config::AIModelDefaultSlot::Mode, + }; + self.config + .set_ai_model_default_for_management(slot, model_id) + .await + .map_err(map_core_management_error) + } +} + +pub(crate) struct RegistryProvider; + +impl RegistryProvider { + pub(crate) fn new() -> Self { + Self + } + + pub(crate) async fn list_skills( + &self, + scope: &ManagementScope, + mode_id: &str, + manageable: bool, + ) -> ManagementResult> { + let workspace = scope.local_workspace("Skill management")?; + let registry = bitfun_core::agentic::tools::implementations::skills::get_skill_registry(); + Ok(if manageable { + registry + .get_mode_skill_infos_for_workspace(Some(workspace), mode_id) + .await + .into_iter() + .map(|info| { + let skill = info.skill; + SkillSummary { + key: skill.key, + name: skill.name, + description: skill.description, + level: skill.level.as_str().to_string(), + source_slot: Some(skill.source_slot), + source_label: Some(skill.source_label), + enabled: info.effective_enabled, + selected_for_runtime: info.selected_for_runtime, + default_enabled: info.default_enabled, + is_shadowed: skill.is_shadowed, + shadowed_by_key: skill.shadowed_by_key, + argument_hint: skill.argument_hint, + } + }) + .collect() + } else { + registry + .get_user_invocable_skills_for_workspace(Some(workspace), Some(mode_id)) + .await + .into_iter() + .map(|skill| SkillSummary { + key: skill.key, + name: skill.name, + description: skill.description, + level: skill.level.as_str().to_string(), + source_slot: Some(skill.source_slot), + source_label: Some(skill.source_label), + enabled: true, + selected_for_runtime: true, + default_enabled: true, + is_shadowed: skill.is_shadowed, + shadowed_by_key: skill.shadowed_by_key, + argument_hint: skill.argument_hint, + }) + .collect() + }) + } + + pub(crate) async fn set_skill_enabled( + &self, + scope: &ManagementScope, + mode_id: &str, + skill_key: &str, + enabled: bool, + default_enabled: bool, + level: &str, + ) -> ManagementResult<()> { + let workspace = scope.local_workspace("Skill management")?; + match level { + "user" => { + bitfun_core::agentic::tools::implementations::skills::mode_overrides::set_user_mode_skill_state( + mode_id, + skill_key, + enabled, + default_enabled, + ) + .await + .map_err(map_core_management_error)?; + } + "project" => { + bitfun_core::agentic::tools::implementations::skills::mode_overrides::set_project_mode_skill_state_local( + workspace, + mode_id, + skill_key, + enabled, + ) + .await + .map_err(map_core_management_error)?; + } + _ => return Err(ManagementError::invalid_request("Unsupported skill level")), + } + Ok(()) + } + + pub(crate) async fn list_subagents( + &self, + scope: &ManagementScope, + parent_mode_id: &str, + management: bool, + external_sources_supported: bool, + ) -> ManagementResult<(Vec, bool)> { + let workspace = scope.local_workspace("Subagent management")?; + let scope = if management { + bitfun_core::agentic::agents::SubagentListScope::RegistryManagement + } else { + bitfun_core::agentic::agents::SubagentListScope::TaskVisible + }; + let catalog = bitfun_core::agentic::agents::get_agent_registry() + .get_subagent_catalog_for_scope( + Some(parent_mode_id), + Some(workspace), + scope, + external_sources_supported, + ) + .await; + let subagents = catalog + .subagents + .into_iter() + .map(|info| { + let source = info + .subagent_source + .unwrap_or(bitfun_core::agentic::agents::SubAgentSource::Builtin); + SubagentSummary { + key: info.key, + id: info.id, + name: info.name, + description: info.description, + source: format!("{source:?}").to_ascii_lowercase(), + enabled: info.effective_enabled, + } + }) + .collect(); + Ok((subagents, catalog.has_external)) + } + + pub(crate) async fn set_subagent_enabled( + &self, + scope: &ManagementScope, + parent_mode_id: &str, + subagent_id: &str, + enabled: bool, + ) -> ManagementResult<()> { + let workspace = scope.local_workspace("Subagent management")?; + bitfun_core::agentic::agents::get_agent_registry() + .update_subagent_override(parent_mode_id, subagent_id, enabled, Some(workspace)) + .await + .map_err(map_core_management_error) + } +} + +fn string_secret_update( + update: Option, +) -> Option> { + update.map(|update| match update { + ModelSecretUpdate::Preserve => bitfun_core::service::config::SecretUpdate::Preserve, + ModelSecretUpdate::Replace(value) => { + bitfun_core::service::config::SecretUpdate::Replace(value) + } + ModelSecretUpdate::Clear => bitfun_core::service::config::SecretUpdate::Clear, + }) +} + +fn model_owner_mutation( + mutation: ModelMutation, +) -> ManagementResult { + let custom_headers = match mutation.custom_headers { + None => None, + Some(ModelSecretUpdate::Preserve) => { + Some(bitfun_core::service::config::SecretUpdate::Preserve) + } + Some(ModelSecretUpdate::Clear) => Some(bitfun_core::service::config::SecretUpdate::Clear), + Some(ModelSecretUpdate::Replace(value)) => { + Some(bitfun_core::service::config::SecretUpdate::Replace( + serde_json::from_str(&value).map_err(|_| { + ManagementError::invalid_request("Custom headers must be valid JSON") + })?, + )) + } + }; + Ok(bitfun_core::service::config::AIModelManagementMutation { + id: mutation.id, + name: mutation.name, + provider: mutation.provider, + model_name: mutation.model_name, + base_url: mutation.base_url, + api_key: string_secret_update(mutation.api_key), + custom_headers, + custom_request_body: string_secret_update(mutation.custom_request_body), + context_window: mutation.context_window, + max_tokens: mutation.max_tokens, + enabled: mutation.enabled, + reasoning: mutation.reasoning, + inline_think_in_text: mutation.inline_think_in_text, + skip_ssl_verify: mutation.skip_ssl_verify, + custom_headers_mode: mutation.custom_headers_mode, + }) +} diff --git a/src/apps/cli/src/tui_management_tests.rs b/src/apps/cli/src/tui_management_tests.rs new file mode 100644 index 0000000000..35b4786b7a --- /dev/null +++ b/src/apps/cli/src/tui_management_tests.rs @@ -0,0 +1,290 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; +use bitfun_core::native_hooks::{ + NativeHookManagementFile, NativeHookManagementHandler, NativeHookManagementOverview, + NativeHookManagementRule, +}; +use bitfun_product_domains::external_source_control::{ + ExternalSourceControlActionV1, ExternalSourceControlRequestV1, + EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, +}; +use bitfun_product_domains::external_sources::{ + ExternalSourceOperationError, ExternalSourceOperationErrorCode, +}; + +use crate::tui_management::{ + project_native_hook_overview, AccountProvider, ExternalCommandProvider, ExternalHookProvider, + ExternalSourceProvider, ManagementErrorKind, ManagementScope, McpProvider, NativeHookProvider, + RegistryProvider, SettingsSyncProvider, WorktreeBindingRequest, WorktreeOwner, + WorktreeProvider, +}; + +#[derive(Default)] +struct CountingWorktreeOwner { + calls: AtomicUsize, +} + +#[async_trait] +impl WorktreeOwner for CountingWorktreeOwner { + async fn bind_session( + &self, + _request: bitfun_core::service::worktree::WorktreeSessionBindingRequest, + ) -> Result< + bitfun_core::service::worktree::WorktreeSessionBindingResult, + bitfun_core_types::WorktreeError, + > { + self.calls.fetch_add(1, Ordering::SeqCst); + panic!("remote requests must not reach the local worktree owner") + } +} + +#[tokio::test] +async fn account_and_settings_validation_precede_optional_owner_lookup() { + let account = AccountProvider::new(None); + let scope = ManagementScope::local("D:/project"); + let invalid = account + .logout(&scope, "bad operation") + .await + .expect_err("invalid account operation id"); + assert_eq!(invalid.kind, ManagementErrorKind::InvalidRequest); + assert_eq!(invalid.message, "Account operation ID is invalid"); + + let unavailable = account + .logout(&scope, "account-op-1") + .await + .expect_err("missing account owner"); + assert_eq!(unavailable.kind, ManagementErrorKind::Unsupported); + assert_eq!(unavailable.message, "tui.account is unavailable"); + + let settings = SettingsSyncProvider::new(None); + let invalid = settings + .cancel(&scope, "bad operation") + .await + .expect_err("invalid settings operation id"); + assert_eq!(invalid.kind, ManagementErrorKind::InvalidRequest); + assert_eq!(invalid.message, "Account operation ID is invalid"); +} + +#[tokio::test] +async fn remote_worktree_binding_is_typed_unsupported_without_local_owner_call() { + let owner = Arc::new(CountingWorktreeOwner::default()); + let provider = WorktreeProvider::new(Some(owner.clone())); + let error = provider + .bind(WorktreeBindingRequest { + scope: ManagementScope::remote("D:/remote/project"), + operation_id: "worktree-op-1".to_string(), + session_id: "session-1".to_string(), + project_workspace_path: None, + }) + .await + .expect_err("remote worktree binding"); + + assert_eq!(error.kind, ManagementErrorKind::Unsupported); + assert_eq!( + error.message, + "Managed worktrees are not supported for remote workspaces" + ); + assert_eq!(owner.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn remote_workspace_management_rejects_before_local_owner_or_input_validation() { + let scope = ManagementScope::remote("/srv/remote-project"); + + let account = AccountProvider::new(None) + .logout(&scope, "bad operation") + .await + .expect_err("remote account access must stop before validation or owner lookup"); + assert_eq!(account.kind, ManagementErrorKind::Unsupported); + + let settings = SettingsSyncProvider::new(None) + .cancel(&scope, "bad operation") + .await + .expect_err("remote settings sync must stop before validation or owner lookup"); + assert_eq!(settings.kind, ManagementErrorKind::Unsupported); + + let skills = RegistryProvider::new() + .list_skills(&scope, "agent", true) + .await + .expect_err("remote skill registry must not read controller-local state"); + assert_eq!(skills.kind, ManagementErrorKind::Unsupported); + + let mcp = McpProvider::new(None) + .toggle(&scope, "server-1") + .await + .expect_err("remote MCP mutation must stop before owner lookup"); + assert_eq!(mcp.kind, ManagementErrorKind::Unsupported); + assert!(mcp.message.contains("remote workspaces")); + + let hooks = NativeHookProvider::new() + .overview(&scope) + .await + .expect_err("remote hook inspection must not read controller-local files"); + assert_eq!(hooks.kind, ManagementErrorKind::Unsupported); + + let external_hooks = ExternalHookProvider::new() + .snapshot(&scope, false) + .await + .expect_err("remote external-hook inspection must fail closed"); + assert_eq!(external_hooks.kind, ManagementErrorKind::Unsupported); + + let source = ExternalSourceProvider::new(); + let invalid_request = ExternalSourceControlRequestV1 { + schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, + operation_id: " leading".to_string(), + expected_preference_revision: None, + action: ExternalSourceControlActionV1::Refresh, + }; + let error = source + .control(&scope, invalid_request) + .await + .expect_err("remote source control must reject before local validation or owner access"); + assert_eq!(error.kind, ManagementErrorKind::Unsupported); +} + +#[tokio::test] +async fn shared_runtime_mcp_management_is_restart_required_without_controller_service() { + let error = McpProvider::shared_runtime_unavailable() + .list(&ManagementScope::local("D:/project")) + .await + .expect_err("Shared TUI must not manage a controller-local MCP duplicate"); + + assert_eq!(error.kind, ManagementErrorKind::Unsupported); + assert!(error.message.contains("Shared Runtime")); + assert!(error.message.contains("restart")); +} + +#[tokio::test] +async fn worktree_operation_id_validation_matches_existing_management_contract() { + let provider = WorktreeProvider::new(None); + let error = provider + .bind(WorktreeBindingRequest { + scope: ManagementScope::local("D:/project"), + operation_id: "bad operation".to_string(), + session_id: "session-1".to_string(), + project_workspace_path: None, + }) + .await + .expect_err("invalid worktree operation id"); + assert_eq!(error.kind, ManagementErrorKind::InvalidRequest); + assert_eq!(error.message, "Worktree operation ID is invalid"); +} + +#[tokio::test] +async fn external_control_and_command_reject_invalid_operation_ids_before_core() { + let source = ExternalSourceProvider::new(); + let error = source + .control( + &ManagementScope::local("D:/project"), + ExternalSourceControlRequestV1 { + schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, + operation_id: " leading".to_string(), + expected_preference_revision: None, + action: ExternalSourceControlActionV1::Refresh, + }, + ) + .await + .expect_err("invalid external source control operation id"); + assert_eq!(error.kind, ManagementErrorKind::InvalidRequest); + assert_eq!( + error.message, + "invalid external source control operation id" + ); + + let command = ExternalCommandProvider::new(); + let error = command + .set_native_choice( + &ManagementScope::local("D:/project"), + " leading", + Vec::new(), + "bitfun.native", + 1, + ) + .await + .expect_err("invalid external command operation id"); + assert_eq!(error.kind, ManagementErrorKind::InvalidRequest); + assert_eq!(error.message, "invalid external source operation id"); +} + +#[test] +fn external_owner_errors_keep_existing_kind_mapping() { + for (code, expected) in [ + ( + ExternalSourceOperationErrorCode::InvalidRequest, + ManagementErrorKind::InvalidRequest, + ), + ( + ExternalSourceOperationErrorCode::NotFound, + ManagementErrorKind::NotFound, + ), + ( + ExternalSourceOperationErrorCode::HostCapabilityUnavailable, + ManagementErrorKind::Unsupported, + ), + ( + ExternalSourceOperationErrorCode::Unsupported, + ManagementErrorKind::Unsupported, + ), + ( + ExternalSourceOperationErrorCode::Internal, + ManagementErrorKind::Internal, + ), + ] { + let error = crate::tui_management::map_external_error(ExternalSourceOperationError::new( + code, + "owner detail", + false, + )); + assert_eq!(error.kind, expected); + assert!(error.message.contains("owner detail")); + } +} + +#[test] +fn native_hook_projection_redacts_paths_and_bounds_commands() { + let overview = NativeHookManagementOverview { + enabled: true, + project_hooks_enabled: true, + files: vec![NativeHookManagementFile { + scope: "project".to_string(), + location: "/.bitfun/config/hooks.json".to_string(), + exists: true, + loaded: true, + }], + rules: vec![NativeHookManagementRule { + event: "PreToolUse".to_string(), + matcher: "Bash".to_string(), + matcher_is_valid: true, + scope: "project".to_string(), + handlers: vec![NativeHookManagementHandler { + command_summary: format!("{}...", "x".repeat(200)), + command_truncated: true, + timeout_seconds: 5, + status_message: Some("Checking".to_string()), + }], + }], + total_handlers: 1, + issues: vec!["Failed to read /.bitfun/config/hooks.json".to_string()], + }; + + let projected = project_native_hook_overview(overview); + assert_eq!( + projected.files[0].location, + "/.bitfun/config/hooks.json" + ); + assert!(projected.rules[0].handlers[0] + .command_summary + .ends_with("...")); + assert_eq!( + projected.rules[0].handlers[0] + .command_summary + .chars() + .count(), + 203 + ); + let debug = format!("{projected:?}"); + assert!(!debug.contains("D:/secret/project")); + assert!(!debug.contains("secret-token")); +} diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index a9105dbf02..51655906df 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -766,9 +766,9 @@ impl ChatView { pub(crate) fn show_account_panel( &mut self, - info: bitfun_app_server_protocol::account::AccountInfo, - devices: Vec, - sync_progress: bitfun_app_server_protocol::account::SettingsSyncProgress, + info: crate::tui_management::AccountInfo, + devices: Vec, + sync_progress: crate::tui_management::SettingsSyncProgress, ) { self.login_form.show_account(info, devices, sync_progress); self.popup_stack.push(PopupType::LoginForm); @@ -781,8 +781,8 @@ impl ChatView { pub(crate) fn update_account_panel_progress( &mut self, - devices: Option>, - sync_progress: bitfun_app_server_protocol::account::SettingsSyncProgress, + devices: Option>, + sync_progress: crate::tui_management::SettingsSyncProgress, ) { self.login_form .update_account_progress(devices, sync_progress); diff --git a/src/apps/cli/src/ui/login_form.rs b/src/apps/cli/src/ui/login_form.rs index 22986598ef..8b6059c1a7 100644 --- a/src/apps/cli/src/ui/login_form.rs +++ b/src/apps/cli/src/ui/login_form.rs @@ -12,10 +12,8 @@ use ratatui::{ Frame, }; +use crate::tui_management::{AccountDevice, AccountInfo, SettingsSyncProgress, SettingsSyncStatus}; use crate::ui::theme::{StyleKind, Theme}; -use bitfun_app_server_protocol::account::{ - AccountDevice, AccountInfo, SettingsSyncProgress, SettingsSyncStatus, -}; /// Credentials collected by the login form. #[derive(Debug, Clone)] diff --git a/src/apps/cli/src/ui/model_config_form.rs b/src/apps/cli/src/ui/model_config_form.rs index 1a108bd510..e50c408d62 100644 --- a/src/apps/cli/src/ui/model_config_form.rs +++ b/src/apps/cli/src/ui/model_config_form.rs @@ -1,4 +1,4 @@ -use bitfun_app_server_protocol::model::{ModelEditProjection, ModelMutation, SecretUpdate}; +use crate::tui_management::{ModelEditProjection, ModelMutation, ModelSecretUpdate}; /// Model configuration form dialog /// /// A multi-field input form for adding a new AI model configuration. @@ -87,20 +87,20 @@ impl ModelFormResult { let editing = self.editing_model_id.is_some(); let required_secret = |value: &str| { if editing && value.is_empty() { - SecretUpdate::Preserve + ModelSecretUpdate::Preserve } else { - SecretUpdate::Replace(value.to_string()) + ModelSecretUpdate::Replace(value.to_string()) } }; let optional_secret = |value: &str| { if value.is_empty() { if editing { - SecretUpdate::Preserve + ModelSecretUpdate::Preserve } else { - SecretUpdate::Clear + ModelSecretUpdate::Clear } } else { - SecretUpdate::Replace(value.to_string()) + ModelSecretUpdate::Replace(value.to_string()) } }; ModelMutation { diff --git a/src/apps/cli/src/ui/question.rs b/src/apps/cli/src/ui/question.rs index 3a5035ddfe..a6e946a2b5 100644 --- a/src/apps/cli/src/ui/question.rs +++ b/src/apps/cli/src/ui/question.rs @@ -39,7 +39,10 @@ struct QuestionData { /// Interactive question prompt state #[derive(Debug, Clone)] pub(crate) struct QuestionPrompt { + pub(crate) session_id: String, + pub(crate) turn_id: String, pub(crate) tool_id: String, + pub(crate) registration_sequence: u64, questions: Vec, /// Current active question tab (0-based); equals questions.len() when on confirm page current_tab: usize, @@ -66,7 +69,13 @@ pub(crate) enum QuestionAction { impl QuestionPrompt { /// Create from parsed AskUserQuestion params - pub(crate) fn from_params(tool_id: String, params: &serde_json::Value) -> Option { + pub(crate) fn from_params( + session_id: String, + turn_id: String, + tool_id: String, + registration_sequence: u64, + params: &serde_json::Value, + ) -> Option { let questions_val = params.get("questions")?.as_array()?; let mut questions = Vec::new(); @@ -118,7 +127,10 @@ impl QuestionPrompt { let q_count = questions.len(); Some(Self { + session_id, + turn_id, tool_id, + registration_sequence, questions, current_tab: 0, answers: vec![Vec::new(); q_count], diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 3c9f34dde4..db6816e43f 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -22,6 +22,10 @@ use crate::actions::{ SHARED_TUI_HELP_NOTE, }; use crate::config::CliConfig; +use crate::tui_management::{ + AddModelRequest, ModelDefaultSlot, SetModelDefaultRequest, SkillSummary, SubagentSummary, + UpdateModelRequest, +}; /// Startup page module /// /// Full-featured startup page with: @@ -30,11 +34,6 @@ use crate::config::CliConfig; /// - Model/Agent/Session/Skill/Subagent selector popups /// - Random tips use anyhow::Result; -use bitfun_app_server_protocol::model::{ - AddModelRequest, ModelDefaultSlot, SetModelDefaultRequest, UpdateModelRequest, -}; -use bitfun_app_server_protocol::skill::SkillSummary; -use bitfun_app_server_protocol::subagent::SubagentSummary; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ backend::Backend, @@ -1373,10 +1372,7 @@ impl StartupPage { } } - fn open_account_panel( - &mut self, - snapshot: bitfun_app_server_protocol::account::AccountSnapshotResponse, - ) { + fn open_account_panel(&mut self, snapshot: crate::tui_management::AccountSnapshotResponse) { let Some(info) = snapshot.info else { self.login_form.show(); return; @@ -1398,8 +1394,8 @@ impl StartupPage { // Refresh devices occasionally while syncing / after done. let devices = if matches!( progress.status, - bitfun_app_server_protocol::account::SettingsSyncStatus::Syncing - | bitfun_app_server_protocol::account::SettingsSyncStatus::Done + crate::tui_management::SettingsSyncStatus::Syncing + | crate::tui_management::SettingsSyncStatus::Done ) { tokio::task::block_in_place(|| { tokio::runtime::Handle::current() @@ -1461,9 +1457,11 @@ impl StartupPage { } LoginFormAction::SyncUseLocal => { let result = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(self.agent.account_finalize_login( - bitfun_app_server_protocol::account::AccountSyncChoice::Local, - )) + tokio::runtime::Handle::current().block_on( + self.agent.account_finalize_login( + crate::tui_management::AccountSyncChoice::Local, + ), + ) }); match result { Ok(snapshot) => { @@ -1483,9 +1481,11 @@ impl StartupPage { } LoginFormAction::SyncUseCloud => { let result = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(self.agent.account_finalize_login( - bitfun_app_server_protocol::account::AccountSyncChoice::Cloud, - )) + tokio::runtime::Handle::current().block_on( + self.agent.account_finalize_login( + crate::tui_management::AccountSyncChoice::Cloud, + ), + ) }); match result { Ok(snapshot) => { @@ -1746,7 +1746,7 @@ impl StartupPage { match result { Ok(response) => { - let form_data = ModelFormResult::from_projection(response.model); + let form_data = ModelFormResult::from_projection(response); self.model_config_form.show_for_edit(&model_id, &form_data); } Err(error) => { diff --git a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs index da2cb83a06..099bb1572c 100644 --- a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs +++ b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs @@ -1,5 +1,93 @@ use std::process::Command; +fn rust_source_files(root: &std::path::Path) -> Vec { + let mut files = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(path) = pending.pop() { + for entry in std::fs::read_dir(&path).expect("read CLI source directory") { + let entry = entry.expect("read CLI source entry"); + let entry_path = entry.path(); + if entry_path.is_dir() { + pending.push(entry_path); + } else if entry_path + .extension() + .is_some_and(|extension| extension == "rs") + { + files.push(entry_path); + } + } + } + files +} + +#[test] +fn embedded_composition_has_no_app_server_route() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let cli_manifest = + std::fs::read_to_string(manifest_dir.join("Cargo.toml")).expect("read CLI Cargo.toml"); + + for retired_dependency in [ + "bitfun-app-server =", + "bitfun-app-server-client =", + "bitfun-app-server-protocol =", + ] { + assert!( + !cli_manifest.contains(retired_dependency), + "Embedded direct-runtime composition must remove the CLI dependency {retired_dependency}" + ); + } + + let retired_source_markers = [ + "EmbeddedAppServerHost", + "AppServerTuiBackend", + "pub(crate) trait TuiBackend", + "Arc", + "in_memory_channel_pair", + "bitfun-embedded-app-server", + "BitfunAppServer::new", + ]; + for source_path in rust_source_files(&manifest_dir.join("src")) { + let source = std::fs::read_to_string(&source_path).expect("read CLI Rust source"); + for retired_marker in retired_source_markers { + assert!( + !source.contains(retired_marker), + "Embedded direct-runtime composition must remove {retired_marker} from {}", + source_path.display() + ); + } + } + assert!( + !manifest_dir.join("src/embedded_app_server.rs").exists(), + "the retired Embedded App Server host module must be deleted" + ); + + const SHARED_RUNTIME: &str = include_str!("../../src/shared_runtime.rs"); + const RUNTIME_CLIENT: &str = include_str!("../../src/agent/runtime_client.rs"); + const RUNTIME_IPC_PROTOCOL: &str = + include_str!("../../../../crates/adapters/agent-runtime-ipc/src/protocol.rs"); + assert!( + cli_manifest.contains("bitfun-agent-runtime-ipc =") + && SHARED_RUNTIME.contains("RuntimeIpcOperation") + && RUNTIME_CLIENT.contains("TuiRuntimePort") + && RUNTIME_CLIENT.contains("Shared(RuntimeIpcClient)") + && RUNTIME_IPC_PROTOCOL.contains("PROTOCOL_VERSION: u32 = 18"), + "Shared TUI must retain the private Runtime IPC v18 compatibility path" + ); + + let workspace_manifest = std::fs::read_to_string(manifest_dir.join("../../../Cargo.toml")) + .expect("read workspace Cargo.toml"); + for retained_member in [ + "src/crates/interfaces/app-server", + "src/crates/interfaces/app-server-client", + "src/crates/interfaces/app-server-protocol", + ] { + assert!( + workspace_manifest.contains(&format!("\"{retained_member}\"")), + "Phase 5 must retain the workspace App Server member {retained_member}" + ); + } +} + #[test] fn doctor_reports_the_validated_cli_runtime_assembly() { let temp = tempfile::tempdir().expect("tempdir"); @@ -211,22 +299,24 @@ fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { } #[test] -fn embedded_account_management_adapts_the_shared_runtime_directly() { - const EMBEDDED_APP_SERVER: &str = include_str!("../../src/embedded_app_server.rs"); +fn embedded_account_management_composes_the_shared_owner_directly() { const CLI_MAIN: &str = include_str!("../../src/main.rs"); - const MANAGEMENT: &str = - include_str!("../../../../crates/interfaces/app-server/src/management.rs"); - const MANAGEMENT_SERVICE: &str = - include_str!("../../../../crates/interfaces/app-server/src/management/service.rs"); - - assert!( - EMBEDDED_APP_SERVER.contains("runtime.account_runtime().clone()") - && MANAGEMENT_SERVICE.contains("Option>") - && MANAGEMENT_SERVICE.contains("login_with_credentials") - && !MANAGEMENT.contains("AccountManagementHost") - && !CLI_MAIN.contains("mod tui_account_management") - && !CLI_MAIN.contains("mod account_sync"), - "Embedded account management must adapt AccountRuntime without a management Host trait" + const MANAGEMENT: &str = include_str!("../../src/tui_management.rs"); + const ACCOUNT_WORKTREE: &str = include_str!("../../src/tui_management/account_worktree.rs"); + + assert!( + CLI_MAIN.contains("Some(runtime.account_runtime().clone())") + && MANAGEMENT.contains("AccountProvider::new(account.clone())") + && MANAGEMENT.contains("SettingsSyncProvider::new(account)") + && ACCOUNT_WORKTREE.contains("login_for_management") + && ACCOUNT_WORKTREE.contains("finalize_login_for_management") + && ACCOUNT_WORKTREE.contains("logout_for_management") + && ACCOUNT_WORKTREE.contains("start_settings_sync_for_management") + && ACCOUNT_WORKTREE.contains("cancel_settings_sync_for_management") + && !MANAGEMENT.contains("trait TuiManagementPort") + && !ACCOUNT_WORKTREE.contains("trait TuiManagementPort") + && !CLI_MAIN.contains("AppManagementService"), + "Embedded account management must compose AccountRuntime without another management owner" ); } @@ -296,47 +386,47 @@ fn local_workspace_snapshot_port_does_not_expand_the_agent_runtime_sdk() { } #[test] -fn interactive_tui_session_client_uses_only_the_app_server_boundary() { - const AGENT_MODULE: &str = include_str!("../../src/agent/mod.rs"); +fn interactive_tui_delegates_runtime_state_to_the_single_cli_client() { const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); - const TUI_BACKEND: &str = include_str!("../../src/tui_backend.rs"); - - assert!( - !AGENT_MODULE.contains("trait Agent"), - "a one-implementation private trait must not obscure the TUI backend boundary" - ); - assert!( - TUI_CLIENT.contains("backend: Arc") - && !TUI_CLIENT.contains("bitfun_agent_runtime::") - && !TUI_CLIENT.contains("bitfun_agent_runtime_ipc") - && !TUI_CLIENT.contains("CoreAgentRuntimeCompatibility"), - "the interactive TUI session client must depend only on TuiBackend contracts" + const RUNTIME_CLIENT: &str = include_str!("../../src/agent/runtime_client.rs"); + let tui_state = TUI_CLIENT + .split_once("pub(crate) struct TuiAgentClient") + .expect("TUI facade state") + .1 + .split_once("impl TuiAgentClient") + .expect("TUI facade implementation") + .0; + + assert!( + tui_state.contains("runtime: Arc") + && !tui_state.contains("session_id:") + && !tui_state.contains("current_turn_id:") + && !tui_state.contains("approval_policy:") + && !tui_state.contains("RuntimeIpcClient"), + "the TUI facade must not rebuild Runtime state or transport behavior" + ); + assert!( + RUNTIME_CLIENT.contains("pub(crate) struct CliAgentRuntimeClient") + && RUNTIME_CLIENT.contains("enum TuiRuntimePort") + && RUNTIME_CLIENT.contains("Embedded(AgentRuntime)") + && RUNTIME_CLIENT.contains("Shared(RuntimeIpcClient)"), + "Direct and Shared deployments must share one stateful CLI Runtime client" ); - assert!( - TUI_BACKEND.contains("pub(crate) trait TuiBackend") - && TUI_BACKEND.contains("AppServerClient") - && !TUI_BACKEND.contains("bitfun_agent_runtime") - && !TUI_BACKEND.contains("use bitfun_core::") - && TUI_CLIENT.contains("use crate::tui_backend::{TuiBackend, TuiBackendError"), - "TuiBackend must remain CLI-local and depend only on App Server client contracts" - ); - for backend_operation in [ - ".sync_session(", - ".submit_dialog_turn(", - ".respond_permission(", - ".fork_session(", - ".session_usage(", - ".wait_for_settlement(", + for operation in [ + "self.runtime.list_sessions()", + "self.runtime.respond_permission(request_id, reply)", + "self.runtime.fork_current_session(before_turn_id)", + "self.runtime.generate_session_usage_report(request)", ] { assert!( - TUI_CLIENT.contains(backend_operation), - "interactive session client must route {backend_operation} through TuiBackend" + TUI_CLIENT.contains(operation), + "interactive Runtime operation must delegate through CliAgentRuntimeClient: {operation}" ); } } #[test] -fn chat_context_reload_uses_the_same_tui_backend_as_session_operations() { +fn chat_context_reload_uses_the_same_runtime_client_as_session_operations() { const CHAT_MODE: &str = include_str!("../../src/modes/chat.rs"); const CHAT_CAPABILITIES: &str = include_str!("../../src/modes/chat/capabilities.rs"); const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); @@ -353,8 +443,8 @@ fn chat_context_reload_uses_the_same_tui_backend_as_session_operations() { "TUI capability code must not branch context reload by Runtime deployment" ); assert!( - TUI_CLIENT.contains(".reload_context(ReloadContextRequest(request))"), - "the TUI session client must delegate reload to TuiBackend" + TUI_CLIENT.contains("self.runtime.reload_context(request).await"), + "the TUI facade must delegate reload to the shared Runtime client" ); } @@ -375,7 +465,7 @@ fn tui_client_covers_interactive_permission_operations() { } #[test] -fn interactive_tui_agent_operations_stay_behind_app_server_backend() { +fn interactive_tui_runtime_and_management_boundaries_do_not_leak_into_controllers() { const STARTUP_PAGE: &str = include_str!("../../src/ui/startup.rs"); const CHAT_MODE: &str = include_str!("../../src/modes/chat.rs"); const CHAT_RUN: &str = include_str!("../../src/modes/chat/run.rs"); @@ -383,8 +473,8 @@ fn interactive_tui_agent_operations_stay_behind_app_server_backend() { const CHAT_INPUT: &str = include_str!("../../src/modes/chat/input.rs"); const CHAT_SELECTION: &str = include_str!("../../src/modes/chat/selection.rs"); const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); - const SHARED_TUI_BACKEND: &str = include_str!("../../src/shared_tui_backend.rs"); - const EMBEDDED_APP_SERVER: &str = include_str!("../../src/embedded_app_server.rs"); + const RUNTIME_CLIENT: &str = include_str!("../../src/agent/runtime_client.rs"); + const MANAGEMENT: &str = include_str!("../../src/tui_management.rs"); const SHARED_RUNTIME: &str = include_str!("../../src/shared_runtime.rs"); const CLI_MAIN: &str = include_str!("../../src/main.rs"); const CLI_CARGO: &str = include_str!("../../Cargo.toml"); @@ -416,40 +506,47 @@ fn interactive_tui_agent_operations_stay_behind_app_server_backend() { "Shared TUI must use the private Runtime IPC adapter without making CLI depend on SDK Host" ); assert!( - SHARED_TUI_BACKEND.contains("RuntimeIpcClient") + RUNTIME_CLIENT.contains("Shared(RuntimeIpcClient)") && !TUI_CLIENT.contains("RuntimeIpcClient") && !STARTUP_PAGE.contains("RuntimeIpcClient") && !CHAT_MODE.contains("RuntimeIpcClient"), - "Shared IPC must remain in the CLI Host adapter instead of leaking into TUI clients or controllers" + "Shared IPC must remain in the Runtime adapter instead of leaking into TUI presentation" ); assert!( - SHARED_TUI_BACKEND - .contains("RuntimeIpcOperation::UpdateSessionMode { request: request.0 }") + RUNTIME_CLIENT.contains("RuntimeIpcOperation::UpdateSessionMode { request }") && SHARED_RUNTIME.contains("RuntimeIpcOperation::UpdateSessionMode { request }") && SHARED_RUNTIME.contains(".update_session_mode(request)"), "Shared Agent mode updates must reuse the Runtime port through the private IPC adapter" ); assert!( - SHARED_TUI_BACKEND - .contains("RuntimeIpcOperation::UpdateSessionModel { request: request.0 }") + RUNTIME_CLIENT.contains("RuntimeIpcOperation::UpdateSessionModel { request }") && SHARED_RUNTIME.contains("RuntimeIpcOperation::UpdateSessionModel { request }") && SHARED_RUNTIME.contains(".update_session_model(request)"), "Shared model updates must reuse the Runtime port through the private IPC adapter" ); - assert!( - TUI_CLIENT.contains(".external_source_snapshot(ExternalSourceSnapshotRequest") - && TUI_CLIENT.contains(".external_source_control(ExternalSourceControlRequest") - && TUI_CLIENT.contains(".external_source_review(ExternalSourceReviewRequest") + let external_source_methods = TUI_CLIENT + .split_once("pub(crate) async fn external_source_snapshot") + .expect("external source snapshot method") + .1 + .split_once("pub(crate) async fn set_native_command_choice") + .expect("external source method boundary") + .0; + assert!( + external_source_methods.matches(".external_source").count() >= 3 + && external_source_methods.contains(".snapshot(") + && external_source_methods.contains(".control(") + && external_source_methods.contains(".review(") && CHAT_COMMANDS.contains("self.agent.external_source_snapshot(false)") && !CHAT_COMMANDS.contains("bitfun_core::external_sources"), - "TUI external-source controllers must route reads and mutations through the typed backend" + "TUI external-source controllers must route reads and mutations through the owner provider" ); assert!( CHAT_COMMANDS.matches("if self.agent.is_shared()").count() >= 3 - && EMBEDDED_APP_SERVER.contains("AppServerTuiBackend::new(client)") + && MANAGEMENT.contains("pub(crate) struct TuiManagementOwners") + && !MANAGEMENT.contains("trait TuiManagementPort") && SHARED_RUNTIME.contains("RuntimeDeployment::Shared") && SHARED_RUNTIME.contains("process_manager::contain_current_process_tree"), - "Shared controls must stay terminal-safe while preserving Embedded recovery and one process Job owner" + "Shared controls must stay terminal-safe while management remains a concrete owner composition" ); assert!( CLI_MAIN.contains("Cli::command()") && CLI_MAIN.contains("McpAction::Import"), @@ -458,11 +555,11 @@ fn interactive_tui_agent_operations_stay_behind_app_server_backend() { } #[test] -fn interactive_tui_hook_management_stays_behind_the_typed_backend() { +fn interactive_tui_hook_management_stays_behind_owner_providers() { const CHAT_HOOKS: &str = include_str!("../../src/modes/chat/external_hooks.rs"); const CHAT_NATIVE_HOOKS: &str = include_str!("../../src/modes/chat/native_hooks.rs"); const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); - const SHARED_TUI_BACKEND: &str = include_str!("../../src/shared_tui_backend.rs"); + const HOOK_MANAGEMENT: &str = include_str!("../../src/tui_management/hooks.rs"); for operation in [ "external_hook_snapshot", @@ -489,10 +586,9 @@ fn interactive_tui_hook_management_stays_behind_the_typed_backend() { } assert!( CHAT_HOOKS.contains("expected_revision") - && SHARED_TUI_BACKEND.contains("NATIVE_HOOKS_CAPABILITY") - && SHARED_TUI_BACKEND.contains("EXTERNAL_HOOKS_CAPABILITY") - && SHARED_TUI_BACKEND.contains("does not fall back"), - "Hook mutations must preserve stale-revision fencing and remote fail-closed routing" + && HOOK_MANAGEMENT.contains("pub(crate) struct NativeHookProvider") + && HOOK_MANAGEMENT.contains("pub(crate) struct ExternalHookProvider"), + "Hook mutations must preserve stale-revision fencing and explicit owner routing" ); assert!( !CHAT_HOOKS.contains("post_call_hooks") @@ -503,14 +599,11 @@ fn interactive_tui_hook_management_stays_behind_the_typed_backend() { } #[test] -fn interactive_tui_worktrees_stay_behind_the_typed_backend() { +fn interactive_tui_worktrees_stay_behind_the_owner_provider() { const WORKTREE_CONTROLLER: &str = include_str!("../../src/modes/chat/worktree.rs"); const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); - const TUI_BACKEND: &str = include_str!("../../src/tui_backend.rs"); - const SHARED_BACKEND: &str = include_str!("../../src/shared_tui_backend.rs"); - const WORKTREE_MANAGEMENT: &str = - include_str!("../../../../crates/interfaces/app-server/src/management/worktree.rs"); - const EMBEDDED_APP_SERVER: &str = include_str!("../../src/embedded_app_server.rs"); + const MANAGEMENT: &str = include_str!("../../src/tui_management.rs"); + const ACCOUNT_WORKTREE: &str = include_str!("../../src/tui_management/account_worktree.rs"); const CLI_MAIN: &str = include_str!("../../src/main.rs"); for direct_owner in [ @@ -531,25 +624,21 @@ fn interactive_tui_worktrees_stay_behind_the_typed_backend() { "worktree_release_session", ] { assert!( - WORKTREE_CONTROLLER.contains(operation) - && TUI_CLIENT.contains(operation) - && TUI_BACKEND.contains(operation) - && SHARED_BACKEND.contains(operation), - "Worktree operation {operation} must stay behind the typed TUI backend" + WORKTREE_CONTROLLER.contains(operation) && TUI_CLIENT.contains(operation), + "Worktree operation {operation} must stay behind the TUI facade" ); } assert!( - WORKTREE_MANAGEMENT.contains("WorktreeService::bind_session") - && EMBEDDED_APP_SERVER.contains("load_for_local_host") - && !EMBEDDED_APP_SERVER.contains("LocalWorktreeManagement") - && !EMBEDDED_APP_SERVER.contains("tui_worktree_management"), - "the Embedded Host must enable the App Server's built-in local Worktree management" + ACCOUNT_WORKTREE.contains("WorktreeService::bind_session") + && MANAGEMENT.contains("WorktreeProvider::local()") + && ACCOUNT_WORKTREE + .contains("Managed worktrees are not supported for remote workspaces"), + "Embedded must compose the Worktree owner while remote bindings fail closed" ); assert!( - SHARED_BACKEND.contains("WORKTREES_CAPABILITY") - && SHARED_BACKEND.contains("does not fall back") - && CLI_MAIN.contains("AppManagementService::load().await?"), - "Shared Worktree management must fail closed" + CLI_MAIN.contains("TuiManagementOwners::load(None, false)") + && !CLI_MAIN.contains("AppManagementService"), + "Shared Worktree management must remain unavailable without a local owner" ); } diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 2620d9925d..a2216b9c76 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -20,8 +20,8 @@ use bitfun_agent_runtime::sdk::{ AgentInputAttachment, AgentSessionCreateResult, AgentSessionModeUpdateRequest, AgentSessionModelSelection, AgentSessionModelSelectionUpdateRequest, AgentSessionModelUpdateRequest, AgentSubmissionSource, AgentTurnCancellationRequest, - DialogSteerOutcome, PermissionAuditRecord, PermissionGrant, PermissionGrantKey, - PermissionReply, PermissionRequest, + DialogSteerOutcome, PendingUserInput, PermissionAuditRecord, PermissionGrant, + PermissionGrantKey, PermissionReply, PermissionRequest, }; use bitfun_core::agentic::agents::AgentSource; use bitfun_core::agentic::coordination::{ @@ -551,6 +551,7 @@ pub struct RestoreSessionWithTurnsResponse { pub struct RestoreSessionViewResponse { pub session: SessionResponse, pub turns: Vec, + pub pending_user_inputs: Vec, pub current_context_usage: Option, pub turn_catalog: SessionTurnCatalog, pub context_restore_state: String, @@ -3330,6 +3331,7 @@ pub async fn restore_session_view( .map_err(|error| format!("Failed to restore session view: {error}"))?; let session = restored.session; let mut turns = restored.turns; + let pending_user_inputs = restored.pending_user_inputs; let current_context_usage = restored.current_context_usage; let total_turn_count = restored.total_turn_count; let turn_catalog = restored.turn_catalog; @@ -3383,6 +3385,7 @@ pub async fn restore_session_view( Ok(RestoreSessionViewResponse { session: session_to_response_with_turn_count(session, total_turn_count), turns, + pending_user_inputs, current_context_usage, turn_catalog, context_restore_state: "pending".to_string(), diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 06dbc09ad0..d7fdc7a133 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -409,13 +409,19 @@ pub async fn execute_tool(request: ToolExecutionRequest) -> Result, + session_id: String, + turn_id: String, tool_id: String, + registration_sequence: u64, answers: serde_json::Value, ) -> Result<(), String> { runtime .agent_runtime() .submit_user_answers(AgentUserAnswersRequest { + session_id, + turn_id, tool_id: tool_id.clone(), + registration_sequence, answers, }) .await diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index a9731a7074..994d774ce3 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -13,7 +13,7 @@ use bitfun_agent_runtime::sdk::{ AgentLocalCommandTurnRecordRequest, AgentRuntime, AgentSessionArchiveStateRequest, AgentSessionDeleteRequest, AgentSessionForkAtTurnRequest, AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionRenameRequest, AgentSessionUsageRequest, - PortErrorKind, RuntimeError, + PendingUserInput, PortErrorKind, RuntimeError, SessionState, }; use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogScheduler}; use bitfun_core::agentic::core::Session; @@ -125,6 +125,7 @@ fn local_command_turn_record_request( pub(crate) struct DesktopSessionViewRestore { pub session: Session, pub turns: Vec, + pub pending_user_inputs: Vec, pub current_context_usage: Option, pub total_turn_count: usize, pub turn_catalog: SessionTurnCatalog, @@ -787,6 +788,18 @@ impl DesktopSessionApplication { .loaded_session_snapshot(session_id) .map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))?; overlay_live_session_state(&mut session, live_session); + let pending_user_inputs = match &session.state { + SessionState::Processing { + current_turn_id, .. + } => self + .compatibility + .pending_user_inputs_for_session(session_id) + .map_err(desktop_core_session_error)? + .into_iter() + .filter(|pending| pending.turn_id == current_turn_id.as_str()) + .collect(), + _ => Vec::new(), + }; let current_context_usage = self .compatibility .load_persisted_session_metadata(&storage_path, session_id) @@ -797,6 +810,7 @@ impl DesktopSessionApplication { Ok(DesktopSessionViewRestore { session, turns, + pending_user_inputs, current_context_usage, total_turn_count, turn_catalog, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index 3a99c59a07..d1b64be6c0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -122,6 +122,7 @@ export interface RemoteCommand { known_model_catalog_version?: number; turn_id?: string; tool_id?: string; + registration_sequence?: number; model_id?: string; reason?: string; mode?: RemotePermissionMode; @@ -143,6 +144,9 @@ export interface PairChallengeResponse { export interface CommandStatusResponse { resp?: string; message?: string; + code?: string; + capability?: string; + recovery?: string; } export interface PermissionModeResponse extends CommandStatusResponse { @@ -158,6 +162,7 @@ export interface WorkspaceInfoResponse extends CommandStatusResponse { git_branch?: string; workspace_kind?: string; assistant_id?: string; + capabilities?: string[]; } export interface RecentWorkspaceEntryResponse { @@ -247,6 +252,7 @@ export interface InitialSyncResponse extends CommandStatusResponse { sessions?: SessionItemResponse[]; has_more_sessions?: boolean; authenticated_user_id?: string; + capabilities?: string[]; } export interface DelegatedIdentityResponse extends CommandStatusResponse { @@ -353,6 +359,14 @@ export interface RemoteToolStatusResponse { result_preview?: string; error_preview?: string; exit_code?: number; + user_input_identity?: RemoteUserInputIdentity; +} + +export interface RemoteUserInputIdentity { + session_id: string; + turn_id: string; + tool_id: string; + registration_sequence: number; } export interface RemoteQuestionAnswerPayload { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets index b0cf70479a..d6f61f475c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets @@ -1,4 +1,4 @@ -import { ConversationUiQuestionAnswer } from '../state/ConversationUiModels'; +import { ConversationUiQuestionAnswer, ConversationUiUserInputIdentity } from '../state/ConversationUiModels'; import { FilePreviewRequest } from '../../model/FilePreviewTarget'; export enum ConversationIntentType { @@ -34,6 +34,7 @@ export class ConversationIntent { readonly toolId: string; readonly updatedInput?: Object; readonly answers?: ConversationUiQuestionAnswer; + readonly userInputIdentity?: ConversationUiUserInputIdentity; readonly filePreviewRequest?: FilePreviewRequest; constructor( @@ -42,7 +43,8 @@ export class ConversationIntent { toolId: string = '', updatedInput?: Object, answers?: ConversationUiQuestionAnswer, - filePreviewRequest?: FilePreviewRequest + filePreviewRequest?: FilePreviewRequest, + userInputIdentity?: ConversationUiUserInputIdentity ) { this.type = type; this.value = value; @@ -50,6 +52,7 @@ export class ConversationIntent { this.updatedInput = updatedInput; this.answers = answers; this.filePreviewRequest = filePreviewRequest; + this.userInputIdentity = userInputIdentity; } } @@ -70,8 +73,16 @@ export class ConversationIntents { return new ConversationIntent(type, '', toolId); } - static answerQuestion(toolId: string, answers: ConversationUiQuestionAnswer): ConversationIntent { - return new ConversationIntent(ConversationIntentType.AnswerQuestion, '', toolId, undefined, answers); + static answerQuestion(identity: ConversationUiUserInputIdentity, answers: ConversationUiQuestionAnswer): ConversationIntent { + return new ConversationIntent( + ConversationIntentType.AnswerQuestion, + '', + identity.tool_id, + undefined, + answers, + undefined, + identity + ); } static openFilePreview(reference: string, label: string): ConversationIntent { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets index 9ce3a59e6a..c72888d577 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets @@ -1,7 +1,7 @@ -import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteModels'; +import { RemoteQuestionAnswerPayload, RemoteSession, RemoteUserInputIdentity } from '../../model/RemoteModels'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; import { ConversationIntent, ConversationIntentType } from './ConversationIntent'; -import { toRemoteQuestionAnswer } from '../state/ConversationUiModels'; +import { toRemoteQuestionAnswer, toRemoteUserInputIdentity } from '../state/ConversationUiModels'; import { FilePreviewRequest } from '../../model/FilePreviewTarget'; export interface ConversationIntentDispatcherHooks { @@ -23,7 +23,7 @@ export interface ConversationIntentDispatcherHooks { readonly approve: (toolId: string, input?: Object) => Promise; readonly reject: (toolId: string) => Promise; readonly cancel: (toolId: string) => Promise; - readonly answer: (toolId: string, answers: RemoteQuestionAnswerPayload) => Promise; + readonly answer: (identity: RemoteUserInputIdentity, answers: RemoteQuestionAnswerPayload) => Promise; readonly rename: (title: string) => Promise; readonly copy: (text: string) => Promise; readonly retry: (text: string) => Promise; @@ -71,7 +71,13 @@ export class ConversationIntentDispatcher { case ConversationIntentType.RejectTool: void this.hooks.reject(intent.toolId); return; case ConversationIntentType.CancelTool: void this.hooks.cancel(intent.toolId); return; case ConversationIntentType.AnswerQuestion: - if (intent.answers) void this.hooks.answer(intent.toolId, toRemoteQuestionAnswer(intent.answers)); return; + if (intent.answers && intent.userInputIdentity) { + void this.hooks.answer( + toRemoteUserInputIdentity(intent.userInputIdentity), + toRemoteQuestionAnswer(intent.answers) + ); + } + return; case ConversationIntentType.RenameSession: void this.hooks.rename(intent.value); return; case ConversationIntentType.CopyMessage: void this.hooks.copy(intent.value); return; case ConversationIntentType.RetryMessage: void this.hooks.retry(intent.value); return; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index fb82970c09..7ca261aad2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -1,4 +1,4 @@ -import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus } from '../state/ConversationUiModels'; +import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus, ConversationUiUserInputIdentity } from '../state/ConversationUiModels'; import { ActivityGroupPolicy } from '../policy/ActivityGroupPolicy'; import { AssistantProcessLayoutPolicy } from '../policy/AssistantProcessLayoutPolicy'; import { ConversationMessageRenderPolicy } from '../policy/ConversationMessageRenderPolicy'; @@ -49,8 +49,8 @@ export struct ChatMessageBubble { (_toolId: string, _updatedInput?: Object) => {}; @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; @Event onCancelTool: (toolId: string) => void = (_toolId: string) => {}; - @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = - (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Event onAnswerQuestion: (identity: ConversationUiUserInputIdentity, answers: ConversationUiQuestionAnswer) => void = + (_identity: ConversationUiUserInputIdentity, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @@ -293,8 +293,8 @@ export struct ChatMessageBubble { onCancelTool: (toolId: string) => { this.onCancelTool(toolId); }, - onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => { - this.onAnswerQuestion(toolId, answers); + onAnswerQuestion: (identity: ConversationUiUserInputIdentity, answers: ConversationUiQuestionAnswer) => { + this.onAnswerQuestion(identity, answers); }, onOpenFilePreview: (path: string, label: string) => { this.onOpenFilePreview(path, label); @@ -736,7 +736,7 @@ export struct ChatMessageBubble { private stableToolHash(tools: ConversationUiToolStatus[]): string { const parts: string[] = []; tools.forEach((tool: ConversationUiToolStatus) => { - parts.push(`${tool.id || ''}:${tool.name || ''}:${tool.status || ''}`); + parts.push(`${this.toolIdentityKey(tool)}:${tool.name || ''}:${tool.status || ''}`); }); return this.stableTextHash(parts.join('|')); } @@ -789,6 +789,11 @@ export struct ChatMessageBubble { private toolCoverageKeys(tool: ConversationUiToolStatus): string[] { const keys: string[] = []; + const identityKey = this.toolIdentityKey(tool); + if (tool.user_input_identity) { + keys.push(`identity:${identityKey}`); + return keys; + } const toolId = tool.id || ''; if (toolId.length > 0) { keys.push(`id:${toolId}`); @@ -800,6 +805,14 @@ export struct ChatMessageBubble { return keys; } + private toolIdentityKey(tool: ConversationUiToolStatus): string { + const identity = tool.user_input_identity; + if (identity) { + return `${identity.session_id}:${identity.turn_id}:${identity.tool_id}:${identity.registration_sequence}`; + } + return tool.id || ''; + } + private toolFingerprint(tool: ConversationUiToolStatus, includeStatus: boolean): string { const parts: string[] = [ this.normalizedToolName(tool), @@ -908,7 +921,7 @@ export struct ChatMessageBubble { return `${path}-thinking`; } if (entry.tool && entry.tool.id) { - return `${path}-tool-${entry.tool.id}`; + return `${path}-tool-${this.toolIdentityKey(entry.tool)}`; } const content = entry.content || ''; const childCount = entry.subItems ? entry.subItems.length : 0; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index afe6df14f7..6c44181ad1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -1,4 +1,4 @@ -import { ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, toConversationUiMessage } from '../state/ConversationUiModels'; +import { ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiUserInputIdentity, toConversationUiMessage } from '../state/ConversationUiModels'; import { ConversationMessageRenderPolicy } from '../policy/ConversationMessageRenderPolicy'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; @@ -33,8 +33,8 @@ export struct ChatTimeline { (_toolId: string, _updatedInput?: Object) => {}; @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; @Event onCancelTool: (toolId: string) => void = (_toolId: string) => {}; - @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = - (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Event onAnswerQuestion: (identity: ConversationUiUserInputIdentity, answers: ConversationUiQuestionAnswer) => void = + (_identity: ConversationUiUserInputIdentity, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @@ -233,8 +233,8 @@ export struct ChatTimeline { onCancelTool: (toolId: string) => { this.onCancelTool(toolId); }, - onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => { - this.onAnswerQuestion(toolId, answers); + onAnswerQuestion: (identity: ConversationUiUserInputIdentity, answers: ConversationUiQuestionAnswer) => { + this.onAnswerQuestion(identity, answers); }, onCopyMessage: (text: string) => { this.onCopyMessage(text); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 6f2951c77f..46311e0b95 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -13,7 +13,8 @@ import { ConversationUiModelCatalog, ConversationUiQuestionAnswer, ConversationUiSelectedImage, - ConversationUiSession + ConversationUiSession, + ConversationUiUserInputIdentity } from '../state/ConversationUiModels'; import { ComposerBar, ComposerPresentation } from './ComposerBar'; import { ConversationHeader } from './ConversationHeader'; @@ -77,8 +78,8 @@ export struct ConversationView { (_toolId: string, _updatedInput?: Object) => {}; @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; @Event onCancelTool: (toolId: string) => void = (_toolId: string) => {}; - @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = - (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Event onAnswerQuestion: (identity: ConversationUiUserInputIdentity, answers: ConversationUiQuestionAnswer) => void = + (_identity: ConversationUiUserInputIdentity, _answers: ConversationUiQuestionAnswer) => {}; @Event onRenameSession: (title: string) => void = (_title: string) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @@ -261,8 +262,8 @@ export struct ConversationView { onCancelTool: (toolId: string) => { this.onCancelTool(toolId); }, - onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => { - this.onAnswerQuestion(toolId, answers); + onAnswerQuestion: (identity: ConversationUiUserInputIdentity, answers: ConversationUiQuestionAnswer) => { + this.onAnswerQuestion(identity, answers); }, onCopyMessage: (text: string) => { this.onCopyMessage(text); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets index 5a64e50332..44a64ad498 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets @@ -5,7 +5,7 @@ import { ConversationIntents, ConversationIntentType } from '../actions/ConversationIntent'; -import { ConversationUiQuestionAnswer } from '../state/ConversationUiModels'; +import { ConversationUiQuestionAnswer, ConversationUiUserInputIdentity } from '../state/ConversationUiModels'; import { ComposerPresentation } from './ComposerBar'; @ComponentV2 @@ -67,8 +67,8 @@ export struct ConversationViewHost { onApproveTool: (id: string, input?: Object) => this.dispatch(ConversationIntents.approveTool(id, input)), onRejectTool: (id: string) => this.dispatch(ConversationIntents.tool(ConversationIntentType.RejectTool, id)), onCancelTool: (id: string) => this.dispatch(ConversationIntents.tool(ConversationIntentType.CancelTool, id)), - onAnswerQuestion: (id: string, answers: ConversationUiQuestionAnswer) => - this.dispatch(ConversationIntents.answerQuestion(id, answers)), + onAnswerQuestion: (identity: ConversationUiUserInputIdentity, answers: ConversationUiQuestionAnswer) => + this.dispatch(ConversationIntents.answerQuestion(identity, answers)), onRenameSession: (title: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.RenameSession, title)), onCopyMessage: (text: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.CopyMessage, text)), onRetryMessage: (text: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.RetryMessage, text)), diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets index 5427bad5a4..360b4e7549 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets @@ -1,5 +1,5 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ConversationUiQuestionAnswer } from '../state/ConversationUiModels'; +import { ConversationUiQuestionAnswer, ConversationUiUserInputIdentity } from '../state/ConversationUiModels'; import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; interface QuestionOption { @@ -131,11 +131,16 @@ export struct ToolConfirmationPanel { @ComponentV2 export struct ToolQuestionAnswerPanel { - @Param toolId: string = ''; + @Param userInputIdentity: ConversationUiUserInputIdentity = { + session_id: '', + turn_id: '', + tool_id: '', + registration_sequence: 0 + }; @Param questionsJson: string = ''; @Param isBusy: boolean = false; - @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = - (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Event onAnswerQuestion: (identity: ConversationUiUserInputIdentity, answers: ConversationUiQuestionAnswer) => void = + (_identity: ConversationUiUserInputIdentity, _answers: ConversationUiQuestionAnswer) => {}; @Local questions: QuestionInput[] = []; @Local selectedAnswers: Map = new Map(); @Local customAnswers: Map = new Map(); @@ -234,7 +239,11 @@ export struct ToolQuestionAnswerPanel { } private canSubmit(): boolean { - if (this.isBusy || this.toolId.length === 0 || this.questions.length === 0) { + if (this.isBusy || + this.userInputIdentity.session_id.length === 0 || + this.userInputIdentity.turn_id.length === 0 || + this.userInputIdentity.tool_id.length === 0 || + this.questions.length === 0) { return false; } return this.questions.every((_question: QuestionInput, index: number) => { @@ -279,7 +288,7 @@ export struct ToolQuestionAnswerPanel { const value: string | string[] = question.multiSelect === true ? selected : selected[0]; (answers as Record)[`${index}`] = value as Object; }); - this.onAnswerQuestion(this.toolId, answers); + this.onAnswerQuestion(this.userInputIdentity, answers); } private parseQuestions(value: string): QuestionInput[] { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets index 76129e0c28..728938eeb0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets @@ -1,4 +1,4 @@ -import { ConversationUiQuestionAnswer, ConversationUiToolStatus } from '../state/ConversationUiModels'; +import { ConversationUiQuestionAnswer, ConversationUiToolStatus, ConversationUiUserInputIdentity } from '../state/ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ToolFileReference, ToolFileReferenceResolver } from '../../services/ToolFileReferenceResolver'; import { ToolCollapseGroup, ToolCollapsePolicy } from '../policy/ToolCollapsePolicy'; @@ -60,8 +60,8 @@ export struct ToolStatusList { (_toolId: string, _updatedInput?: Object) => {}; @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; @Event onCancelTool: (toolId: string) => void = (_toolId: string) => {}; - @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = - (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Event onAnswerQuestion: (identity: ConversationUiUserInputIdentity, answers: ConversationUiQuestionAnswer) => void = + (_identity: ConversationUiUserInputIdentity, _answers: ConversationUiQuestionAnswer) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Local expanded: boolean = false; @@ -146,7 +146,7 @@ export struct ToolStatusList { } if (this.isQuestionTool(tool)) { ToolQuestionAnswerPanel({ - toolId: tool.id || '', + userInputIdentity: tool.user_input_identity!, questionsJson: this.questionInputJson(tool), isBusy: this.isBusy, onAnswerQuestion: this.onAnswerQuestion @@ -944,6 +944,7 @@ export struct ToolStatusList { const status = (tool.status || '').toLowerCase(); const name = this.normalizedToolName(tool); return (name === 'askuserquestion' || name === 'ask_user_question') && + tool.user_input_identity !== undefined && status !== 'completed' && status !== 'done' && status !== 'failed' && @@ -1000,6 +1001,10 @@ export struct ToolStatusList { private toolKey(tool: ConversationUiToolStatus, index: number): string { const signature = this.toolSignature(tool); + const identity = tool.user_input_identity; + if (identity) { + return `${identity.session_id}-${identity.turn_id}-${identity.tool_id}-${identity.registration_sequence}-${signature}`; + } if (tool.id && tool.id.length > 0) { return `${index}-${tool.id}-${signature}`; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 7455ff4a68..d668efd1cd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -4,6 +4,7 @@ import { RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, + RemoteUserInputIdentity, SelectedImageAttachment, SessionSummary, WorkspaceInfo @@ -763,8 +764,8 @@ export abstract class AppRootRuntimeComposition { }, reject: async (id: string): Promise => { await this.conversationController.rejectRemoteTool(id); }, cancel: async (id: string): Promise => { await this.conversationController.cancelRemoteTool(id); }, - answer: async (id: string, answers: RemoteQuestionAnswerPayload): Promise => { - await this.conversationController.answerRemoteQuestion(id, answers); + answer: async (identity: RemoteUserInputIdentity, answers: RemoteQuestionAnswerPayload): Promise => { + await this.conversationController.answerRemoteQuestion(identity, answers); }, rename: async (title: string): Promise => { await this.conversationController.renameVisibleSession(title); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets index 5de53cba77..cc6857834e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets @@ -8,6 +8,7 @@ import { RemoteModelConfig, RemoteReasoningCatalogProjection, RemoteQuestionAnswerPayload, + RemoteUserInputIdentity, SelectedImageAttachment, SessionSummary, RemoteToolStatusResponse @@ -60,6 +61,14 @@ export interface ConversationUiToolStatus { result_preview?: string; error_preview?: string; exit_code?: number; + user_input_identity?: ConversationUiUserInputIdentity; +} + +export interface ConversationUiUserInputIdentity { + session_id: string; + turn_id: string; + tool_id: string; + registration_sequence: number; } export interface ConversationUiMessageItem { @@ -172,7 +181,22 @@ export function toConversationUiToolStatus(source: RemoteToolStatusResponse): Co tool_output: source.tool_output, result_preview: source.result_preview, error_preview: source.error_preview, - exit_code: source.exit_code + exit_code: source.exit_code, + user_input_identity: source.user_input_identity ? { + session_id: source.user_input_identity.session_id, + turn_id: source.user_input_identity.turn_id, + tool_id: source.user_input_identity.tool_id, + registration_sequence: source.user_input_identity.registration_sequence + } : undefined + }; +} + +export function toRemoteUserInputIdentity(source: ConversationUiUserInputIdentity): RemoteUserInputIdentity { + return { + session_id: source.session_id, + turn_id: source.turn_id, + tool_id: source.tool_id, + registration_sequence: source.registration_sequence }; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets index c877a88fc7..5def5b1564 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets @@ -1,6 +1,7 @@ import { RemoteQuestionAnswerPayload, RemoteSession, + RemoteUserInputIdentity, SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; @@ -171,8 +172,11 @@ export class ConversationController { } async rejectRemoteTool(toolId: string): Promise { await this.transcript.rejectRemoteTool(toolId); } async cancelRemoteTool(toolId: string): Promise { await this.transcript.cancelRemoteTool(toolId); } - async answerRemoteQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { - await this.transcript.answerRemoteQuestion(toolId, answers); + async answerRemoteQuestion( + identity: RemoteUserInputIdentity, + answers: RemoteQuestionAnswerPayload + ): Promise { + await this.transcript.answerRemoteQuestion(identity, answers); } resetRemoteTimeline(sessionId: string): void { this.transcript.resetRemoteTimeline(sessionId); } syncRemoteTimeline(): void { this.transcript.syncRemoteTimeline(); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets index f5082ddb43..280f33cb5f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets @@ -1,6 +1,7 @@ import { RemoteImageContext, - RemoteQuestionAnswerPayload + RemoteQuestionAnswerPayload, + RemoteUserInputIdentity } from '../../model/RemoteModels'; import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { ChatTimelineState, ChatTimelineStore } from '../../services/ChatTimelineStore'; @@ -209,10 +210,13 @@ export class RemoteTranscriptController { ); } - async answerRemoteQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { + async answerRemoteQuestion( + identity: RemoteUserInputIdentity, + answers: RemoteQuestionAnswerPayload + ): Promise { const runtime = requireRemoteRuntime(this.remoteRuntime); await runtime.tools.answer( - toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable(), answers + identity, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable(), answers ); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets index ef2cdca7bf..f2b79c0e14 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets @@ -282,6 +282,8 @@ export class ChatTimelineProjector { parts.push([ `${index}`, tool.id || '', + tool.user_input_identity ? + `${tool.user_input_identity.session_id}:${tool.user_input_identity.turn_id}:${tool.user_input_identity.tool_id}:${tool.user_input_identity.registration_sequence}` : '', tool.name || '', tool.status || '', `${tool.duration_ms || 0}`, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets index ef90f6c172..4fed9a7c3d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets @@ -471,8 +471,13 @@ export class ChatTimelineStore { if (!active) { return; } - const tools = (active.tools || []).filter((item: RemoteToolStatusResponse) => item.id !== tool.id); - tools.push(tool); + const tools = (active.tools || []).slice(); + const matchIndex = ChatTimelineStore.matchToolIndex(tools, tool); + if (matchIndex >= 0) { + tools[matchIndex] = ChatTimelineStore.mergeTool(tools[matchIndex], tool) || tool; + } else { + tools.push(tool); + } this.setActiveTurn({ id: active.id, turnId: active.turnId, @@ -641,7 +646,7 @@ export class ChatTimelineStore { if (incomingToolId.length > 0) { matchIndex = previousItems.findIndex((previous: ChatMessageItemResponse, index: number) => { return !matched.has(index) && previous.tool !== undefined && - (previous.tool.id || '') === incomingToolId; + ChatTimelineStore.sameToolIdentity(previous.tool, incoming.tool); }); } else if (incomingIndex < previousItems.length && !matched.has(incomingIndex) && @@ -687,7 +692,7 @@ export class ChatTimelineStore { const previousToolId = previous.tool ? (previous.tool.id || '') : ''; const incomingToolId = incoming.tool ? (incoming.tool.id || '') : ''; if (previousToolId.length > 0 || incomingToolId.length > 0) { - return previousToolId === incomingToolId; + return ChatTimelineStore.sameToolIdentity(previous.tool, incoming.tool); } return true; } @@ -748,6 +753,44 @@ export class ChatTimelineStore { return rank(incomingStatus) < rank(previousStatus) ? previous : incoming; } + private static matchToolIndex( + tools: RemoteToolStatusResponse[], + incoming: RemoteToolStatusResponse + ): number { + const exactIndex = tools.findIndex((tool: RemoteToolStatusResponse) => { + return ChatTimelineStore.sameToolIdentity(tool, incoming); + }); + if (exactIndex >= 0 || incoming.user_input_identity) { + return exactIndex; + } + const sameId: number[] = []; + tools.forEach((tool: RemoteToolStatusResponse, index: number) => { + if ((tool.id || '') === (incoming.id || '')) { + sameId.push(index); + } + }); + return sameId.length === 1 ? sameId[0] : -1; + } + + private static sameToolIdentity( + left: RemoteToolStatusResponse | undefined, + right: RemoteToolStatusResponse | undefined + ): boolean { + if (!left || !right) { + return false; + } + const leftIdentity = left.user_input_identity; + const rightIdentity = right.user_input_identity; + if (leftIdentity || rightIdentity) { + return !!leftIdentity && !!rightIdentity && + leftIdentity.session_id === rightIdentity.session_id && + leftIdentity.turn_id === rightIdentity.turn_id && + leftIdentity.tool_id === rightIdentity.tool_id && + leftIdentity.registration_sequence === rightIdentity.registration_sequence; + } + return (left.id || '') === (right.id || ''); + } + private static isActiveTurnId(id: string): boolean { return id.indexOf('active-') === 0; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets index c00e33d725..cfc34e3d58 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets @@ -289,7 +289,10 @@ export class CloudAccountClient { ); const parsed = Encoding.parseJsonObject(Encoding.bytesToUtf8(plain)); if (parsed.resp === 'error') { - throw new Error(parsed.message || 'Remote device command failed.'); + const message = parsed.message || 'Remote device command failed.'; + const recovery = parsed.code === 'upgrade_required' && parsed.recovery ? + ` ${parsed.recovery}` : ''; + throw new Error(`${message}${recovery}`); } return parsed; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RelayHttpClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RelayHttpClient.ets index 9fc50aed1d..9840e9f472 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RelayHttpClient.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RelayHttpClient.ets @@ -215,7 +215,10 @@ export class RelayHttpClient { if (response.resp === 'error') { RemoteLogger.error(`command remote error cmd=${commandName} request=${requestId} message=${response.message || 'unknown'}`); if (throwOnRemoteError) { - throw new Error(response.message || 'Remote command failed.'); + const message = response.message || 'Remote command failed.'; + const recovery = response.code === 'upgrade_required' && response.recovery ? + ` ${response.recovery}` : ''; + throw new Error(`${message}${recovery}`); } return response; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets index 466bbdf424..af4fb86047 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets @@ -1,4 +1,4 @@ -import { CreateSessionOptions, RemoteCommand, RemoteImageContext, RemotePermissionMode, RemoteQuestionAnswerPayload } from '../model/RemoteModels'; +import { CreateSessionOptions, RemoteCommand, RemoteImageContext, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteUserInputIdentity } from '../model/RemoteModels'; export class RemoteCommandFactory { static getWorkspaceInfo(): RemoteCommand { @@ -162,10 +162,13 @@ export class RemoteCommandFactory { return { cmd: 'set_permission_mode', mode }; } - static answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): RemoteCommand { + static answerQuestion(identity: RemoteUserInputIdentity, answers: RemoteQuestionAnswerPayload): RemoteCommand { return { cmd: 'answer_question', - tool_id: toolId, + session_id: identity.session_id, + turn_id: identity.turn_id, + tool_id: identity.tool_id, + registration_sequence: identity.registration_sequence, answers }; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 58074a47c9..ac4a327a24 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -1,4 +1,4 @@ -import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; +import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, RemoteUserInputIdentity, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { PairIdentity, PeerDeviceProvisionOutcome, RelayHttpClient } from './RelayHttpClient'; import { CloudAccountClient, CloudAccountSession } from './CloudAccountClient'; @@ -28,6 +28,11 @@ export interface ProvisionedPeerDevice { deviceId: string; } +const ANSWER_QUESTION_IDENTITY_CAPABILITY: string = 'answer_question_identity_v1'; +const ANSWER_QUESTION_UPGRADE_MESSAGE: string = + 'This Desktop version cannot safely answer interactive questions. ' + + 'Upgrade Desktop, reconnect, and answer the question again.'; + export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteSessionClient, RemoteToolActionClient { private readonly roomClient: RelayHttpClient = new RelayHttpClient(); private transport?: RemoteCommandTransport; @@ -35,12 +40,14 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile private crypto?: RemoteCrypto; private workspace?: WorkspaceInfo; private roomRelayUrl: string = ''; + private remoteCapabilities: string[] = []; reset(): void { this.transportGeneration += 1; this.crypto = undefined; this.workspace = undefined; this.roomRelayUrl = ''; + this.remoteCapabilities = []; this.transport?.reset(); this.transport = undefined; this.roomClient.reset(); @@ -54,6 +61,7 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile this.transport?.reset(); this.transport = new RoomRemoteCommandTransport(this.roomClient); const initialSync = await this.roomClient.pair(deviceId, identity); + this.remoteCapabilities = initialSync.capabilities || []; // Account inheritance is optional for room pairing. A desktop without an // account, or an older desktop build, must not make normal QR control fail. await this.roomClient.requestDelegatedIdentity(); @@ -119,9 +127,11 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile targetDeviceId: string ): Promise { this.transportGeneration += 1; + this.remoteCapabilities = []; this.transport?.reset(); this.transport = new AccountDeviceCommandTransport(accountClient, relayUrl, session, targetDeviceId); const workspaceResponse = await this.send(RemoteCommandFactory.getWorkspaceInfo()); + this.remoteCapabilities = workspaceResponse.capabilities || []; this.workspace = RemoteResponseMapper.workspaceFromResponse(workspaceResponse); const sessions = await this.listSessions(50, 0, '', ''); return { @@ -134,6 +144,7 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile async getWorkspaceInfo(): Promise { const response = await this.send(RemoteCommandFactory.getWorkspaceInfo()); + this.remoteCapabilities = response.capabilities || []; return RemoteResponseMapper.workspaceFromResponse(response); } @@ -355,8 +366,11 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile return response.mode || mode; } - async answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { - await this.send(RemoteCommandFactory.answerQuestion(toolId, answers)); + async answerQuestion(identity: RemoteUserInputIdentity, answers: RemoteQuestionAnswerPayload): Promise { + if (this.remoteCapabilities.indexOf(ANSWER_QUESTION_IDENTITY_CAPABILITY) < 0) { + throw new Error(ANSWER_QUESTION_UPGRADE_MESSAGE); + } + await this.send(RemoteCommandFactory.answerQuestion(identity, answers)); } async getFileInfo(path: string, sessionId?: string): Promise { @@ -433,6 +447,12 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile if (generation !== this.transportGeneration || transport !== this.transport) { throw new Error('Remote target changed while the request was in flight.'); } + if (response.resp === 'upgrade_required') { + const message = response.message ? response.message.trim() : ''; + const recovery = response.recovery ? response.recovery.trim() : ''; + const detail = [message, recovery].filter((item: string) => item.length > 0).join(' '); + throw new Error(detail.length > 0 ? detail : ANSWER_QUESTION_UPGRADE_MESSAGE); + } return response; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteToolActionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteToolActionController.ets index de4ddd166e..624ba51e1b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteToolActionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteToolActionController.ets @@ -1,4 +1,4 @@ -import { RemoteQuestionAnswerPayload } from '../model/RemoteModels'; +import { RemoteQuestionAnswerPayload, RemoteUserInputIdentity } from '../model/RemoteModels'; import { RemoteI18n } from '../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; import { RemoteLogger } from './RemoteLogger'; @@ -7,7 +7,7 @@ export interface RemoteToolActionClient { confirmTool(toolId: string, updatedInput?: Object): Promise; rejectTool(toolId: string, reason: string): Promise; cancelTool(toolId: string, reason: string): Promise; - answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise; + answerQuestion(identity: RemoteUserInputIdentity, answers: RemoteQuestionAnswerPayload): Promise; } export class RemoteToolActionController { @@ -74,20 +74,23 @@ export class RemoteToolActionController { } async answer( - toolId: string, + identity: RemoteUserInputIdentity, sessionId: string, remoteAvailable: boolean, answers: RemoteQuestionAnswerPayload ): Promise { - RemoteLogger.info(`question answer requested tool=${RemoteToolActionController.shortId(toolId)} session=${RemoteToolActionController.shortId(sessionId)} fields=${Object.keys(answers).length}`); + if (identity.session_id !== sessionId) { + return; + } + RemoteLogger.info(`question answer requested tool=${RemoteToolActionController.shortId(identity.tool_id)} session=${RemoteToolActionController.shortId(sessionId)} fields=${Object.keys(answers).length}`); await this.runToolAction( - toolId, + identity.tool_id, sessionId, remoteAvailable, RemoteI18n.t('status.submittingAnswer'), RemoteI18n.t('status.answerSubmitted'), () => { - return this.client.answerQuestion(toolId, answers); + return this.client.answerQuestion(identity, answers); } ); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets index 0204acf61f..307ddca7d9 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets @@ -118,6 +118,7 @@ import { CreateSessionOptions, RemoteImageContext, RemoteQuestionAnswerPayload, + RemoteUserInputIdentity, RemoteModelCatalog, ReadFileChunkResult, ReadFileResult, @@ -769,8 +770,8 @@ export class FakeRemoteToolActionClient implements RemoteToolActionClient { this.failIfNeeded(); } - async answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { - this.actions.push(`answer:${toolId}:${JSON.stringify(answers)}`); + async answerQuestion(identity: RemoteUserInputIdentity, answers: RemoteQuestionAnswerPayload): Promise { + this.actions.push(`answer:${identity.session_id}:${identity.turn_id}:${identity.tool_id}:${identity.registration_sequence}:${JSON.stringify(answers)}`); this.failIfNeeded(); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index 123935c563..1015fa1e67 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -53,7 +53,7 @@ import { ModelProviderSseParser } from '../main/ets/services/general-chat/ModelP import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGeneralChatAdapter'; import { MarkdownParser } from '../main/ets/services/MarkdownParser'; import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; -import { toRemoteQuestionAnswer } from '../main/ets/pages/state/ConversationUiModels'; +import { toConversationUiToolStatus, toRemoteQuestionAnswer } from '../main/ets/pages/state/ConversationUiModels'; import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; @@ -113,6 +113,7 @@ import { CreateSessionOptions, RemoteImageContext, RemoteQuestionAnswerPayload, + RemoteUserInputIdentity, RemoteModelCatalog, ReadFileResult, RemoteSession, @@ -1340,24 +1341,175 @@ export default function transportAndGeneralChatUnitTest() { it('builds tool and file commands', 0, () => { const answers: RemoteQuestionAnswerPayload = { answer: 'yes', '0': 'yes' }; const multipleAnswers: RemoteQuestionAnswerPayload = { '0': 'yes', '1': ['tests', 'docs'] }; + const identity: RemoteUserInputIdentity = { + session_id: 'session-1', + turn_id: 'turn-1', + tool_id: 'tool-2', + registration_sequence: 7 + }; + const secondIdentity: RemoteUserInputIdentity = { + session_id: 'session-1', + turn_id: 'turn-1', + tool_id: 'tool-3', + registration_sequence: 7 + }; expectCommandJson(RemoteCommandFactory.confirmTool('tool-1'), '{"cmd":"confirm_tool","tool_id":"tool-1"}'); expectCommandJson(RemoteCommandFactory.rejectTool('tool-1', 'no'), '{"cmd":"reject_tool","tool_id":"tool-1","reason":"no"}'); expectCommandJson(RemoteCommandFactory.cancelTool('tool-1', 'stop'), '{"cmd":"cancel_tool","tool_id":"tool-1","reason":"stop"}'); - expectCommandJson(RemoteCommandFactory.answerQuestion('tool-2', answers), '{"cmd":"answer_question","tool_id":"tool-2","answers":{"0":"yes","answer":"yes"}}'); - expectCommandJson(RemoteCommandFactory.answerQuestion('tool-3', multipleAnswers), '{"cmd":"answer_question","tool_id":"tool-3","answers":{"0":"yes","1":["tests","docs"]}}'); + expectCommandJson(RemoteCommandFactory.answerQuestion(identity, answers), '{"cmd":"answer_question","session_id":"session-1","turn_id":"turn-1","tool_id":"tool-2","registration_sequence":7,"answers":{"0":"yes","answer":"yes"}}'); + expectCommandJson(RemoteCommandFactory.answerQuestion(secondIdentity, multipleAnswers), '{"cmd":"answer_question","session_id":"session-1","turn_id":"turn-1","tool_id":"tool-3","registration_sequence":7,"answers":{"0":"yes","1":["tests","docs"]}}'); expectCommandJson(RemoteCommandFactory.getFileInfo('/tmp/a.txt', 's1'), '{"cmd":"get_file_info","path":"/tmp/a.txt","session_id":"s1"}'); expectCommandJson(RemoteCommandFactory.readFileChunk('/tmp/a.txt', 12, 64, 's1'), '{"cmd":"read_file_chunk","path":"/tmp/a.txt","offset":12,"limit":64,"session_id":"s1"}'); }); it('preserves indexed question answers when dispatching from the UI', 0, () => { const answers = toRemoteQuestionAnswer({ '0': 'tests', '1': ['docs', 'src'] }); + const identity: RemoteUserInputIdentity = { + session_id: 'session-2', + turn_id: 'turn-4', + tool_id: 'tool-4', + registration_sequence: 9 + }; expectCommandJson( - RemoteCommandFactory.answerQuestion('tool-4', answers), - '{"cmd":"answer_question","tool_id":"tool-4","answers":{"0":"tests","1":["docs","src"]}}' + RemoteCommandFactory.answerQuestion(identity, answers), + '{"cmd":"answer_question","session_id":"session-2","turn_id":"turn-4","tool_id":"tool-4","registration_sequence":9,"answers":{"0":"tests","1":["docs","src"]}}' ); }); + + it('preserves the authoritative question identity in the UI projection', 0, () => { + const projected = toConversationUiToolStatus({ + id: 'tool-5', + name: 'AskUserQuestion', + status: 'waiting_for_user', + user_input_identity: { + session_id: 'session-5', + turn_id: 'turn-5', + tool_id: 'tool-5', + registration_sequence: 11 + } + }); + + expect(projected.user_input_identity?.session_id).assertEqual('session-5'); + expect(projected.user_input_identity?.turn_id).assertEqual('turn-5'); + expect(projected.user_input_identity?.tool_id).assertEqual('tool-5'); + expect(projected.user_input_identity?.registration_sequence).assertEqual(11); + }); + + it('blocks question answers locally without negotiated identity capability', 0, async () => { + const manager = new RemoteSessionManager(); + const identity: RemoteUserInputIdentity = { + session_id: 'session-1', + turn_id: 'turn-1', + tool_id: 'tool-1', + registration_sequence: 7 + }; + let message = ''; + + try { + await manager.answerQuestion(identity, { '0': 'yes' }); + } catch (err) { + message = err instanceof Error ? err.message : `${err}`; + } + + expect(message).assertEqual( + 'This Desktop version cannot safely answer interactive questions. ' + + 'Upgrade Desktop, reconnect, and answer the question again.' + ); + }); + + it('keeps same-tool question registrations distinct in live timeline events', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-1'); + [7, 8].forEach((sequence: number) => { + store.applyEvent({ + type: 'tool_started', + sessionId: 'session-1', + turnId: 'turn-1', + tool: { + id: 'tool-1', + name: 'AskUserQuestion', + status: 'waiting_for_user', + user_input_identity: { + session_id: 'session-1', + turn_id: 'turn-1', + tool_id: 'tool-1', + registration_sequence: sequence + } + } + }); + }); + + const tools = store.snapshot().activeTurn?.tools || []; + expect(tools.length).assertEqual(2); + expect(tools[0].user_input_identity?.registration_sequence).assertEqual(7); + expect(tools[1].user_input_identity?.registration_sequence).assertEqual(8); + }); + + it('merges a settled question by registration identity before adding the next one', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-1'); + store.setActiveTurn({ + id: 'active-turn-1', + role: 'assistant', + text: '', + status: 'waiting_for_user', + turnId: 'turn-1', + tools: [{ + id: 'tool-1', + name: 'AskUserQuestion', + status: 'waiting_for_user', + user_input_identity: { + session_id: 'session-1', + turn_id: 'turn-1', + tool_id: 'tool-1', + registration_sequence: 7 + } + }] + }); + store.setActiveTurn({ + id: 'active-turn-1', + role: 'assistant', + text: '', + status: 'running', + turnId: 'turn-1', + tools: [{ + id: 'tool-1', + name: 'AskUserQuestion', + status: 'completed', + user_input_identity: { + session_id: 'session-1', + turn_id: 'turn-1', + tool_id: 'tool-1', + registration_sequence: 7 + } + }] + }); + store.applyEvent({ + type: 'tool_started', + sessionId: 'session-1', + turnId: 'turn-1', + tool: { + id: 'tool-1', + name: 'AskUserQuestion', + status: 'waiting_for_user', + user_input_identity: { + session_id: 'session-1', + turn_id: 'turn-1', + tool_id: 'tool-1', + registration_sequence: 8 + } + } + }); + + const tools = store.snapshot().activeTurn?.tools || []; + expect(tools.length).assertEqual(2); + expect(tools[0].status).assertEqual('completed'); + expect(tools[0].user_input_identity?.registration_sequence).assertEqual(7); + expect(tools[1].status).assertEqual('waiting_for_user'); + expect(tools[1].user_input_identity?.registration_sequence).assertEqual(8); + }); }); describe('RemoteResponseMapper', () => { diff --git a/src/apps/mobile/harmonyos/tools/fake-relay.mjs b/src/apps/mobile/harmonyos/tools/fake-relay.mjs index 609a7ddfdf..c8d6bbe9cd 100644 --- a/src/apps/mobile/harmonyos/tools/fake-relay.mjs +++ b/src/apps/mobile/harmonyos/tools/fake-relay.mjs @@ -167,6 +167,15 @@ let questionAnswered = false; let selectedModelId = 'model-primary-preview'; const deletedSessions = new Set(); +function questionIdentity(toolId) { + return { + session_id: createdSession.id, + turn_id: 'turn-preview-1', + tool_id: toolId, + registration_sequence: 1, + }; +} + const assistants = [ { path: '/workspace/.bitfun/assistants/daily', @@ -454,6 +463,7 @@ function currentMessages() { id: 'tool-question-1', name: 'AskUserQuestion', status: questionAnswered ? 'completed' : 'pending', + user_input_identity: questionIdentity('tool-question-1'), input_preview: JSON.stringify({ questions: [ { @@ -494,6 +504,7 @@ function activeTurnPayload(status = 'active') { id: 'tool-active-2', name: 'AskUserQuestion', status: questionAnswered ? 'completed' : 'pending', + user_input_identity: questionIdentity('tool-active-2'), input_preview: JSON.stringify({ questions: [ { @@ -792,6 +803,14 @@ function responseFor(command) { toolCancelled = true; return { resp: 'ok', action: 'cancel_tool', target_id: command.tool_id }; case 'answer_question': + if ( + command.session_id !== createdSession.id || + command.turn_id !== 'turn-preview-1' || + !['tool-question-1', 'tool-active-2'].includes(command.tool_id) || + command.registration_sequence !== 1 + ) { + return { resp: 'error', error: 'Question identity mismatch.' }; + } questionAnswered = true; return { resp: 'ok' }; case 'get_file_info': diff --git a/src/apps/server/src/app_server.rs b/src/apps/server/src/app_server.rs index 6fc25d0ca9..3d6b3affb3 100644 --- a/src/apps/server/src/app_server.rs +++ b/src/apps/server/src/app_server.rs @@ -29,4 +29,5 @@ use bitfun_app_server::{BitfunAppRuntime, BitfunAppServer}; pub(crate) fn build(runtime: AgentRuntime, event_source: AgentEventSource) -> BitfunAppServer { let app_runtime = BitfunAppRuntime::new(runtime, event_source); BitfunAppServer::new(app_runtime) + .with_max_frame_bytes(crate::routes::websocket::MAX_WS_TEXT_BYTES as u64) } diff --git a/src/apps/server/src/routes/websocket.rs b/src/apps/server/src/routes/websocket.rs index e4c2629348..0f7abb6805 100644 --- a/src/apps/server/src/routes/websocket.rs +++ b/src/apps/server/src/routes/websocket.rs @@ -36,7 +36,7 @@ use bitfun_app_server::BitfunAppServer; use crate::AppState; /// Maximum accepted WS frame size (256 KiB), matching the prior envelope handler. -const MAX_WS_TEXT_BYTES: usize = 256 * 1024; +pub(crate) const MAX_WS_TEXT_BYTES: usize = 256 * 1024; /// WebSocket connection handler. /// diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index 5dd337d4a9..d2e76c0b27 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -42,7 +42,9 @@ pub struct RuntimeSessionForkRequest { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RuntimeUserAnswersRequest { pub session_id: String, + pub turn_id: String, pub tool_id: String, + pub registration_sequence: u64, pub answers: serde_json::Value, } @@ -354,6 +356,7 @@ pub enum RuntimeIpcOperationResult { workspace_binding: AgentSessionWorkspaceBinding, transcript: SessionTranscript, pending_permissions: Vec, + pending_user_inputs: Vec, }, SessionForked { session: AgentSessionSummary, diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs index 628ad2f908..2b872cbd15 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs @@ -5,7 +5,7 @@ use crate::{RuntimeIpcOperation, RuntimeIpcOperationResult}; use bitfun_events::AgenticEventEnvelope; use bitfun_product_domains::tool_permissions::PermissionRequestEvent; -pub const PROTOCOL_VERSION: u32 = 17; +pub const PROTOCOL_VERSION: u32 = 18; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] diff --git a/src/crates/adapters/agent-runtime-ipc/src/server.rs b/src/crates/adapters/agent-runtime-ipc/src/server.rs index 998d266755..dcf07ee0ce 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/server.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/server.rs @@ -522,6 +522,32 @@ async fn run_initialized_connection( continue; } }; + // Restore is a snapshot-plus-stream handoff. Attach the + // receiver before the owner captures its coherent snapshot so + // events emitted during that capture are buffered for this + // connection instead of falling into a response/subscription + // gap. + let mut preattached_restore_events = + if let RuntimeIpcOperation::RestoreSession { request } = &operation { + match handler.subscribe_events(&request.session_id) { + Ok(receiver) => Some(receiver), + Err(error) => { + config + .leases + .rollback(connection_id, lease_transition.clone()); + send_runtime_error( + stream, + config.request_timeout, + Some(request_id), + error, + ) + .await?; + continue; + } + } + } else { + None + }; if operation.is_interruptible_lineage_read() { let handler = handler.clone(); let request_timeout = config.request_timeout; @@ -639,6 +665,13 @@ async fn run_initialized_connection( .. } ); + let fork_response = matches!( + &response, + RuntimeIpcFrame::Response { + result: RuntimeIpcOperationResult::SessionForked { .. }, + .. + } + ); let response_bytes = match serialize_frame_with_limit( &response, MAX_RESPONSE_FRAME_BYTES, @@ -655,6 +688,18 @@ async fn run_initialized_connection( .await?; return Err(RuntimeIpcServerError::Disconnected); } + Err(_) if fork_response => { + config.leases.rollback(connection_id, lease_transition); + send_error( + stream, + config.request_timeout, + Some(request_id), + RuntimeIpcErrorCode::OutcomeUnknown, + "runtime operation completed but its response could not be encoded within the Shared TUI limit; reconnect and inspect authoritative state before retrying", + ) + .await?; + return Err(RuntimeIpcServerError::Disconnected); + } Err(RuntimeIpcIoError::FrameTooLarge { .. }) => { config.leases.rollback(connection_id, lease_transition); send_error( @@ -674,6 +719,17 @@ async fn run_initialized_connection( let RuntimeIpcFrame::Response { result, .. } = &response else { unreachable!("response frame was just constructed") }; + let restored_active_turn_id = match result { + RuntimeIpcOperationResult::SessionRestored { + state: + crate::RuntimeSessionState::Processing { + current_turn_id, .. + }, + .. + } => Some(Some(current_turn_id.clone())), + RuntimeIpcOperationResult::SessionRestored { .. } => Some(None), + _ => None, + }; let created_session_id = match result { RuntimeIpcOperationResult::SessionCreated { session } => { Some(session.session_id.as_str()) @@ -712,9 +768,17 @@ async fn run_initialized_connection( } _ => None, } { - match handler.subscribe_events(session_id) { - Ok(receiver) => *events = Some(receiver), - Err(_) => event_stream_unavailable = true, + if matches!(result, RuntimeIpcOperationResult::SessionRestored { .. }) { + if let Some(receiver) = preattached_restore_events.take() { + *events = Some(receiver); + } else { + event_stream_unavailable = true; + } + } else { + match handler.subscribe_events(session_id) { + Ok(receiver) => *events = Some(receiver), + Err(_) => event_stream_unavailable = true, + } } } if let RuntimeIpcOperationResult::TurnAccepted { turn_id, .. } = result { @@ -757,6 +821,9 @@ async fn run_initialized_connection( config.leases.rollback(connection_id, lease_transition); return Err(error); } + if let Some(restored_active_turn_id) = restored_active_turn_id { + *active_turn_id = restored_active_turn_id; + } if event_stream_unavailable { return Err(RuntimeIpcServerError::EventStreamUnavailable); } @@ -802,6 +869,7 @@ async fn next_connection_input( interruptible_lineage_reads: &mut JoinSet, ) -> Result { tokio::select! { + biased; frame = read_connected(timeout, stream, frames, frame_deadline) => frame.map(ConnectionInput::Frame), completed = receive_interruptible_lineage_read(interruptible_lineage_reads) => { Ok(ConnectionInput::InterruptibleLineageReadCompleted(completed)) diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index 741f38d2d5..723947be8c 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -19,8 +19,8 @@ use bitfun_runtime_ports::{ use serde_json::{json, Map}; #[test] -fn shared_runtime_protocol_stays_at_version_17() { - assert_eq!(PROTOCOL_VERSION, 17); +fn shared_runtime_protocol_stays_at_version_18() { + assert_eq!(PROTOCOL_VERSION, 18); } #[test] @@ -64,7 +64,9 @@ fn protocol_round_trips_reviewed_permission_and_user_input_operations() { RuntimeIpcOperation::SubmitUserAnswers { request: RuntimeUserAnswersRequest { session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), tool_id: "question-1".to_string(), + registration_sequence: 7, answers: json!({"choice": "yes"}), }, }, @@ -80,7 +82,7 @@ fn protocol_round_trips_reviewed_permission_and_user_input_operations() { #[test] fn protocol_round_trips_read_only_main_agent_catalog() { - assert_eq!(PROTOCOL_VERSION, 17); + assert_eq!(PROTOCOL_VERSION, 18); let operation = RuntimeIpcOperation::ListAgentModes { session_id: Some("session-1".to_string()), }; @@ -135,7 +137,7 @@ fn protocol_round_trips_read_only_main_agent_catalog() { #[test] fn protocol_round_trips_exact_turn_steering_without_replacing_turn_admission() { - assert_eq!(PROTOCOL_VERSION, 17); + assert_eq!(PROTOCOL_VERSION, 18); let operation = RuntimeIpcOperation::SteerTurn { request: AgentDialogSteerRequest { session_id: "session-1".to_string(), @@ -253,7 +255,7 @@ fn protocol_round_trips_root_scoped_lineage_operations() { #[test] fn protocol_round_trips_workspace_diff_as_a_read_only_workspace_operation() { - assert_eq!(PROTOCOL_VERSION, 17); + assert_eq!(PROTOCOL_VERSION, 18); let operation = RuntimeIpcOperation::WorkspaceDiff; let encoded = serde_json::to_value(&operation).expect("serialize workspace diff operation"); @@ -374,7 +376,7 @@ fn protocol_round_trips_the_reviewed_session_model_operation() { #[test] fn protocol_round_trips_the_current_session_rename_operation() { - assert_eq!(PROTOCOL_VERSION, 17); + assert_eq!(PROTOCOL_VERSION, 18); let operation = RuntimeIpcOperation::RenameSession { request: RuntimeSessionRenameRequest { diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs index a63b29e4dd..1294848adc 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs @@ -14,12 +14,12 @@ use bitfun_runtime_ports::{ AgentSessionModelUpdateRequest, AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSubmissionSource, AgentUserShellCommandRequest, DialogSubmissionPolicy, SessionExecutionTarget, - SessionTranscript, + SessionTranscript, TranscriptContent, TranscriptMessage, }; use serde_json::Map; use std::path::Path; use std::sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, Arc, Mutex, }; use std::time::Duration; @@ -95,6 +95,7 @@ struct FakeHandler { lineage_read_delay: Option, invalid_steer_result: bool, settle_cancel: bool, + restored_turn_id: Option, events: broadcast::Sender, available: watch::Sender, } @@ -106,6 +107,78 @@ struct CreateRaceHandler { events: broadcast::Sender, } +struct RestoreSubscriptionHandler { + subscribed: AtomicBool, + events: broadcast::Sender, +} + +struct OversizedForkHandler { + fork_calls: AtomicUsize, + events: broadcast::Sender, +} + +#[async_trait] +impl RuntimeIpcRequestHandler for OversizedForkHandler { + async fn execute( + &self, + operation: RuntimeIpcOperation, + ) -> Result { + match operation { + RuntimeIpcOperation::RestoreSession { request } => Ok(restored(&request.session_id)), + RuntimeIpcOperation::ForkSession { .. } => { + self.fork_calls.fetch_add(1, Ordering::SeqCst); + Ok(RuntimeIpcOperationResult::SessionForked { + session: summary("session-fork"), + transcript: SessionTranscript { + session_id: "session-fork".to_string(), + messages: vec![TranscriptMessage { + id: Some("oversized-message".to_string()), + role: "assistant".to_string(), + turn_id: Some("turn-oversized".to_string()), + timestamp_ms: None, + content: TranscriptContent::Text("x".repeat(9 * 1024 * 1024)), + }], + }, + workspace_binding: workspace_binding(), + }) + } + _ => Ok(RuntimeIpcOperationResult::Unit), + } + } + + fn subscribe_events(&self, _session_id: &str) -> EventSubscription { + Ok(self.events.subscribe()) + } +} + +#[async_trait] +impl RuntimeIpcRequestHandler for RestoreSubscriptionHandler { + async fn execute( + &self, + operation: RuntimeIpcOperation, + ) -> Result { + let RuntimeIpcOperation::RestoreSession { request } = operation else { + return Ok(RuntimeIpcOperationResult::Unit); + }; + if !self.subscribed.load(Ordering::SeqCst) { + return Err(RuntimeIpcError { + code: RuntimeIpcErrorCode::Internal, + message: "restore executed before its event subscription was attached".to_string(), + }); + } + self.events + .send(test_agent_event(&request.session_id, "turn-during-restore")) + .expect("pre-attached restore receiver"); + Ok(restored(&request.session_id)) + } + + fn subscribe_events(&self, _session_id: &str) -> EventSubscription { + let receiver = self.events.subscribe(); + self.subscribed.store(true, Ordering::SeqCst); + Ok(receiver) + } +} + impl Default for FakeHandler { fn default() -> Self { let (events, _) = broadcast::channel(16); @@ -121,6 +194,7 @@ impl Default for FakeHandler { lineage_read_delay: None, invalid_steer_result: false, settle_cancel: true, + restored_turn_id: None, events, available, } @@ -219,7 +293,10 @@ impl RuntimeIpcRequestHandler for FakeHandler { } } match operation { - RuntimeIpcOperation::RestoreSession { request } => Ok(restored(&request.session_id)), + RuntimeIpcOperation::RestoreSession { request } => Ok(match &self.restored_turn_id { + Some(turn_id) => restored_processing(&request.session_id, turn_id), + None => restored(&request.session_id), + }), RuntimeIpcOperation::ForkSession { .. } => { Ok(RuntimeIpcOperationResult::SessionForked { session: summary("session-fork"), @@ -417,6 +494,48 @@ async fn oversized_event_reports_typed_invalidation_before_disconnect() { server.finish().await; } +#[tokio::test] +async fn queued_terminal_and_invalidation_events_precede_runtime_unavailability() { + let handler = Arc::new(FakeHandler::default()); + let server = TestServer::start(server_config(), handler.clone()).await; + let mut client = server.connect("terminal-before-unavailable").await; + expect_response( + &mut client, + 2, + restore_operation(server.workspace.path(), "session-a"), + ) + .await; + + handler + .events + .send(test_agent_event("session-a", "turn-terminal")) + .unwrap(); + handler + .events + .send(RuntimeIpcEvent::StreamInvalidated { + reason: crate::RuntimeIpcStreamInvalidationReason::Closed, + }) + .unwrap(); + handler.available.send_replace(false); + + assert!(matches!( + read_frame(&mut client).await.unwrap(), + RuntimeIpcFrame::Event { + event: RuntimeIpcEvent::Agent { envelope, .. } + } if matches!(envelope.event, AgenticEvent::DialogTurnCancelled { ref turn_id, .. } if turn_id == "turn-terminal") + )); + assert!(matches!( + read_frame(&mut client).await.unwrap(), + RuntimeIpcFrame::Event { + event: RuntimeIpcEvent::StreamInvalidated { + reason: crate::RuntimeIpcStreamInvalidationReason::Closed, + } + } + )); + drop(client); + server.finish().await; +} + #[tokio::test] async fn first_party_timeout_reports_unknown_outcome_and_releases_the_lease() { let mut config = server_config(); @@ -606,6 +725,98 @@ async fn generated_session_is_claimed_before_another_connection_can_restore_it() server.finish().await; } +#[tokio::test] +async fn restore_attaches_event_subscription_before_capturing_the_snapshot() { + let (event_sender, _) = broadcast::channel(16); + let server = TestServer::start( + server_config(), + Arc::new(RestoreSubscriptionHandler { + subscribed: AtomicBool::new(false), + events: event_sender, + }), + ) + .await; + let client = RuntimeIpcClient::connect( + server.runtime_root.path(), + &server.discovery, + "restore-subscription", + "0.1.0", + Duration::from_secs(2), + Duration::from_secs(2), + ) + .await + .expect("connect first-party client"); + let mut client_events = client.subscribe_events(); + + client + .request(restore_operation(server.workspace.path(), "session-a")) + .await + .expect("restore after pre-subscription"); + let event = tokio::time::timeout(Duration::from_secs(1), client_events.recv()) + .await + .expect("restore event deadline") + .expect("restore event"); + assert!(matches!( + event, + crate::RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::Agent { envelope, .. }) + if matches!(envelope.event, AgenticEvent::DialogTurnCancelled { ref turn_id, .. } if turn_id == "turn-during-restore") + )); + + drop(client); + server.finish().await; +} + +#[tokio::test] +async fn processing_restore_reinstates_turn_control_and_disconnect_cancellation() { + let handler = Arc::new(FakeHandler { + restored_turn_id: Some("turn-restored".to_string()), + ..FakeHandler::default() + }); + let server = TestServer::start(server_config(), handler.clone()).await; + let client = RuntimeIpcClient::connect( + server.runtime_root.path(), + &server.discovery, + "processing-restore", + "0.1.0", + Duration::from_secs(2), + Duration::from_secs(2), + ) + .await + .expect("connect first-party client"); + + client + .request(restore_operation(server.workspace.path(), "session-a")) + .await + .expect("restore processing session"); + assert!(matches!( + client.request(steer_operation("session-a", "turn-restored")).await, + Ok(RuntimeIpcOperationResult::TurnSteered { ref turn_id, .. }) if turn_id == "turn-restored" + )); + assert!(matches!( + client + .request(rename_operation("session-a", "blocked while processing")) + .await, + Err(RuntimeIpcClientError::Remote(RuntimeIpcError { + code: RuntimeIpcErrorCode::SessionInUse, + .. + })) + )); + + drop(client); + wait_for_calls(&handler, |calls| { + calls.iter().any(|operation| { + matches!( + operation, + RuntimeIpcOperation::CancelTurn { request } + if request.session_id == "session-a" + && request.turn_id.as_deref() == Some("turn-restored") + ) + }) + }) + .await; + server.finish().await; +} + fn summary(session_id: &str) -> AgentSessionSummary { AgentSessionSummary { session_id: session_id.to_string(), @@ -630,10 +841,36 @@ fn restored(session_id: &str) -> RuntimeIpcOperationResult { messages: Vec::new(), }, pending_permissions: Vec::new(), + pending_user_inputs: Vec::new(), workspace_binding: workspace_binding(), } } +fn restored_processing(session_id: &str, turn_id: &str) -> RuntimeIpcOperationResult { + let RuntimeIpcOperationResult::SessionRestored { + session, + transcript, + pending_permissions, + pending_user_inputs, + workspace_binding, + .. + } = restored(session_id) + else { + unreachable!("restore fixture") + }; + RuntimeIpcOperationResult::SessionRestored { + session, + state: crate::RuntimeSessionState::Processing { + current_turn_id: turn_id.to_string(), + phase: crate::RuntimeSessionProcessingPhase::ToolCalling, + }, + transcript, + pending_permissions, + pending_user_inputs, + workspace_binding, + } +} + fn workspace_binding() -> AgentSessionWorkspaceBinding { AgentSessionWorkspaceBinding { workspace_id: Some("workspace-fixture".to_string()), @@ -1022,6 +1259,52 @@ async fn successful_fork_atomically_transfers_control_and_releases_the_source() server.finish().await; } +#[tokio::test] +async fn oversized_fork_success_is_outcome_unknown_and_closes_before_a_retry() { + let handler = Arc::new(OversizedForkHandler { + fork_calls: AtomicUsize::new(0), + events: broadcast::channel(16).0, + }); + let server = TestServer::start(server_config(), handler.clone()).await; + let client = RuntimeIpcClient::connect( + server.runtime_root.path(), + &server.discovery, + "oversized-fork-controller", + "0.1.0", + Duration::from_secs(2), + Duration::from_secs(2), + ) + .await + .expect("connect first-party client"); + client + .request(restore_operation(server.workspace.path(), "session-a")) + .await + .expect("restore source session"); + + let operation = fork_operation("session-a", Some("turn-2")); + let first = client.request(operation.clone()).await; + assert!( + matches!( + first, + Err(RuntimeIpcClientError::Remote(RuntimeIpcError { + code: RuntimeIpcErrorCode::OutcomeUnknown, + .. + })) + ), + "oversized completed fork must fail closed: {first:?}" + ); + + let retry = client.request(operation).await; + assert!( + matches!(retry, Err(RuntimeIpcClientError::Disconnected)), + "the completed fork connection must reject a replay: {retry:?}" + ); + assert_eq!(handler.fork_calls.load(Ordering::SeqCst), 1); + + drop(client); + server.finish().await; +} + #[tokio::test] async fn one_connection_rejects_a_second_turn_until_the_first_finishes() { let handler = Arc::new(FakeHandler::default()); diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 8904b0e8cc..5ddf402a17 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -46,7 +46,7 @@ pub use registry::catalog::{builtin_agent_specs, BuiltinAgentSpec}; pub(crate) use registry::external_subagent_runtime_key; pub use registry::types::{ subagent_source_from_custom_kind, AgentCategory, AgentInfo, AgentSource, AgentToolPolicy, - CustomSubagentConfig, SubAgentSource, SubagentListScope, SubagentQueryContext, + CustomSubagentConfig, SubAgentSource, SubagentCatalog, SubagentListScope, SubagentQueryContext, SubagentStateReason, }; pub use registry::visibility::{ diff --git a/src/crates/assembly/core/src/agentic/agents/registry/query.rs b/src/crates/assembly/core/src/agentic/agents/registry/query.rs index 2d621735b9..3d10084ebf 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/query.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/query.rs @@ -7,7 +7,7 @@ use super::AgentRegistry; use crate::agentic::agents::registry::types::{is_review_agent_entry, AgentEntry, AgentSource}; use crate::agentic::agents::{ mode_presentation_rank, resolve_mode_config_profile_id, AgentCategory, AgentInfo, - AgentToolPolicy, SubagentListScope, SubagentQueryContext, + AgentToolPolicy, SubAgentSource, SubagentCatalog, SubagentListScope, SubagentQueryContext, }; use crate::agentic::deep_review_policy::canonical_review_worker_agent_type; use crate::agentic::tools::get_all_registered_tool_names; @@ -390,6 +390,38 @@ impl AgentRegistry { Self::sort_subagents_for_presentation(result) } + /// Builds the registry-owned projection for management and task surfaces. + pub async fn get_subagent_catalog_for_scope( + &self, + parent_agent_type: Option<&str>, + workspace_root: Option<&Path>, + list_scope: SubagentListScope, + external_sources_supported: bool, + ) -> SubagentCatalog { + let mut subagents = self + .get_subagents_for_query(&SubagentQueryContext { + parent_agent_type, + workspace_root, + list_scope, + include_disabled: list_scope == SubagentListScope::RegistryManagement, + external_sources_supported, + }) + .await; + if !external_sources_supported { + subagents.retain(|info| info.subagent_source != Some(SubAgentSource::External)); + } + let has_external = subagents + .iter() + .any(|info| info.subagent_source == Some(SubAgentSource::External)); + if list_scope == SubagentListScope::RegistryManagement { + subagents.retain(|info| info.subagent_source != Some(SubAgentSource::External)); + } + SubagentCatalog { + subagents, + has_external, + } + } + pub async fn can_parent_access_subagent( &self, subagent_id: &str, diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index c0636392b3..2af4cd12ee 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -138,6 +138,54 @@ fn insert_project_subagent(registry: &AgentRegistry, workspace: &Path, id: &str, .insert(workspace.to_path_buf(), entries); } +#[tokio::test] +async fn scoped_subagent_catalog_keeps_external_presence_outside_management_entries() { + let registry = AgentRegistry::new(); + registry.register_agent( + Arc::new(TestAgent { + id: "ExternalScout".to_string(), + }), + AgentCategory::SubAgent, + AgentSource::External, + Some(SubAgentSource::External), + None, + ); + + let management = registry + .get_subagent_catalog_for_scope( + Some("agentic"), + None, + SubagentListScope::RegistryManagement, + true, + ) + .await; + assert!(management.has_external); + assert!(management + .subagents + .iter() + .all(|info| info.subagent_source != Some(SubAgentSource::External))); + + let task = registry + .get_subagent_catalog_for_scope(Some("agentic"), None, SubagentListScope::TaskVisible, true) + .await; + assert!(task.has_external); + assert!(task.subagents.iter().any(|info| info.id == "ExternalScout")); + + let remote_safe = registry + .get_subagent_catalog_for_scope( + Some("agentic"), + None, + SubagentListScope::TaskVisible, + false, + ) + .await; + assert!(!remote_safe.has_external); + assert!(remote_safe + .subagents + .iter() + .all(|info| info.subagent_source != Some(SubAgentSource::External))); +} + #[test] fn source_qualified_key_resolves_the_matching_custom_subagent() { let registry = AgentRegistry::new(); diff --git a/src/crates/assembly/core/src/agentic/agents/registry/types.rs b/src/crates/assembly/core/src/agentic/agents/registry/types.rs index 0bcc9bbc2d..f98368f78c 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/types.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/types.rs @@ -110,6 +110,14 @@ pub struct AgentInfo { pub supports_follow_up: bool, } +/// Scoped registry projection for management and task-selection surfaces. +#[derive(Debug, Clone)] +pub struct SubagentCatalog { + pub subagents: Vec, + /// Whether supported external candidates exist before management filtering. + pub has_external: bool, +} + fn default_true() -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index ec0b671fd1..940f27e83c 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -11351,6 +11351,9 @@ fn user_input_port_error( bitfun_agent_runtime::user_questions::UserInputSendError::MissingChannel { .. } => { bitfun_runtime_ports::PortErrorKind::NotFound } + bitfun_agent_runtime::user_questions::UserInputSendError::IdentityMismatch { .. } => { + bitfun_runtime_ports::PortErrorKind::InvalidRequest + } bitfun_agent_runtime::user_questions::UserInputSendError::ChannelClosed { .. } => { bitfun_runtime_ports::PortErrorKind::Cancelled } @@ -11813,12 +11816,13 @@ impl bitfun_agent_runtime::sdk::AgentSessionRestorePort for ConversationCoordina message, ) })?; + let requested_session_id = request.session_id.clone(); let storage_request = SessionStoragePathRequest { workspace_path: PathBuf::from(request.workspace_path), remote_connection_id: request.remote_connection_id, remote_ssh_host: request.remote_ssh_host, }; - let session = if request.include_internal { + if request.include_internal { self.restore_internal_session_for_workspace(storage_request, &request.session_id) .await } else { @@ -11827,6 +11831,28 @@ impl bitfun_agent_runtime::sdk::AgentSessionRestorePort for ConversationCoordina } .map_err(runtime_port_error_preserving_message)?; + let _mutation = self + .session_manager + .acquire_session_mutation(&requested_session_id) + .await + .map_err(runtime_port_error_preserving_message)?; + let session = self + .session_manager + .get_session(&requested_session_id) + .ok_or_else(|| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::OutcomeUnknown, + "Session disappeared while its restore snapshot was being captured", + ) + })?; + let transcript = self + .read_session_transcript_locked(bitfun_runtime_ports::SessionTranscriptRequest { + session_id: requested_session_id.clone(), + turn_id: None, + }) + .await?; + let pending_user_inputs = self.pending_user_inputs_for_session(&requested_session_id); + Ok(bitfun_agent_runtime::sdk::AgentSessionRestoreResult { session: bitfun_runtime_ports::AgentSessionSummary { session_id: session.session_id, @@ -11841,6 +11867,8 @@ impl bitfun_agent_runtime::sdk::AgentSessionRestorePort for ConversationCoordina last_active_at_ms: runtime_session_time_ms(session.last_activity_at), }, state: session.state, + transcript, + pending_user_inputs, }) } } @@ -12345,7 +12373,7 @@ impl bitfun_agent_runtime::sdk::AgentInteractionResponsePort for ConversationCoo request: bitfun_agent_runtime::sdk::AgentUserAnswersRequest, ) -> bitfun_runtime_ports::PortResult<()> { crate::agentic::tools::user_input_manager::get_user_input_manager() - .send_answer(&request.tool_id, request.answers) + .send_answer(request) .map_err(user_input_port_error) } } @@ -12552,6 +12580,105 @@ impl bitfun_runtime_ports::RemoteControlStatePort for ConversationCoordinator { } impl ConversationCoordinator { + fn project_pending_user_input_to_root( + &self, + pending: &mut bitfun_agent_runtime::sdk::PendingUserInput, + ) { + let mut visited = HashSet::new(); + while visited.insert(pending.session_id.clone()) { + let Some((parent_session_id, parent_turn_id)) = self + .active_subagent_executions + .get(&pending.session_id) + .map(|route| { + ( + route.parent_session_id.clone(), + route.parent_dialog_turn_id.clone(), + ) + }) + else { + break; + }; + pending.session_id = parent_session_id; + pending.turn_id = parent_turn_id; + } + } + + pub(crate) async fn register_pending_user_input( + &self, + mut pending: bitfun_agent_runtime::sdk::PendingUserInput, + sender: tokio::sync::oneshot::Sender< + bitfun_agent_runtime::user_questions::UserInputResponse, + >, + ) -> bitfun_agent_runtime::sdk::PendingUserInput { + self.project_pending_user_input_to_root(&mut pending); + pending.registration_sequence = + crate::agentic::tools::user_input_manager::get_user_input_manager() + .register(pending.clone(), sender); + self.emit_event(AgenticEvent::ToolEvent { + session_id: pending.session_id.clone(), + turn_id: pending.turn_id.clone(), + round_id: format!("pending-user-input:{}", pending.tool_id), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputRequested { + identity: ToolEventIdentity::direct(&pending.tool_id, "AskUserQuestion"), + registration_sequence: pending.registration_sequence, + params: pending.input.clone(), + }, + }) + .await; + pending + } + + pub(crate) async fn resolve_pending_user_input( + &self, + pending: &bitfun_agent_runtime::sdk::PendingUserInput, + ) { + let _ = self + .event_queue + .enqueue(Self::pending_user_input_resolved_event(pending), None) + .await; + } + + pub(crate) fn resolve_pending_user_input_now( + &self, + pending: &bitfun_agent_runtime::sdk::PendingUserInput, + ) { + self.event_queue + .enqueue_now(Self::pending_user_input_resolved_event(pending), None); + } + + fn pending_user_input_resolved_event( + pending: &bitfun_agent_runtime::sdk::PendingUserInput, + ) -> AgenticEvent { + AgenticEvent::ToolEvent { + session_id: pending.session_id.clone(), + turn_id: pending.turn_id.clone(), + round_id: format!("pending-user-input:{}", pending.tool_id), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputResolved { + identity: ToolEventIdentity::direct(&pending.tool_id, "AskUserQuestion"), + registration_sequence: pending.registration_sequence, + }, + } + } + + pub(crate) fn pending_user_inputs_for_session( + &self, + session_id: &str, + ) -> Vec { + crate::agentic::tools::user_input_manager::get_user_input_manager() + .pending_inputs() + .into_iter() + .map(|mut pending| { + self.project_pending_user_input_to_root(&mut pending); + pending + }) + .filter(|pending| pending.session_id == session_id) + .collect() + } + async fn read_session_transcript_with_turn_status_locked( &self, request: bitfun_runtime_ports::SessionTranscriptRequest, @@ -13926,13 +14053,27 @@ mod tests { let (sender, receiver) = tokio::sync::oneshot::channel::< bitfun_agent_runtime::user_questions::UserInputResponse, >(); - crate::agentic::tools::user_input_manager::get_user_input_manager() - .register_channel(answer_tool_id.clone(), sender); + let registration_sequence = + crate::agentic::tools::user_input_manager::get_user_input_manager().register( + bitfun_agent_runtime::sdk::PendingUserInput { + tool_id: answer_tool_id.clone(), + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + source_session_id: "session-1".to_string(), + source_turn_id: "turn-1".to_string(), + registration_sequence: 0, + input: serde_json::json!({ "questions": [] }), + }, + sender, + ); AgentInteractionResponsePort::submit_user_answers( &coordinator, AgentUserAnswersRequest { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), tool_id: answer_tool_id.clone(), + registration_sequence, answers: serde_json::json!({ "0": "continue" }), }, ) @@ -13946,7 +14087,10 @@ mod tests { let stale_answer = AgentInteractionResponsePort::submit_user_answers( &coordinator, AgentUserAnswersRequest { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), tool_id: answer_tool_id.clone(), + registration_sequence, answers: serde_json::json!({ "0": "continue" }), }, ) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs index 816136de93..ea99beb3f1 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs @@ -6,17 +6,57 @@ use async_trait::async_trait; use bitfun_agent_runtime::user_questions::{ ask_user_question_available_in_context, build_answered_user_question_result, build_cancelled_user_question_result, validate_ask_user_question_input, AskUserQuestionInput, - USER_INPUT_AVAILABLE_CONTEXT_KEY, + PendingUserInput, USER_INPUT_AVAILABLE_CONTEXT_KEY, }; use log::{debug, warn}; use serde_json::{json, Value}; use uuid::Uuid; +use crate::agentic::coordination::get_global_coordinator; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::agentic::tools::user_input_manager::get_user_input_manager; use crate::infrastructure::events::event_system::{get_global_event_system, BackendEvent}; use crate::util::errors::BitFunResult; +struct PendingUserInputSettlement { + pending: PendingUserInput, + coordinator: Option>, + settled: bool, +} + +impl PendingUserInputSettlement { + fn new( + pending: PendingUserInput, + coordinator: Option>, + ) -> Self { + Self { + pending, + coordinator, + settled: false, + } + } + + async fn settle(mut self) { + get_user_input_manager().cancel_registration(&self.pending); + if let Some(coordinator) = self.coordinator.as_ref() { + coordinator.resolve_pending_user_input(&self.pending).await; + } + self.settled = true; + } +} + +impl Drop for PendingUserInputSettlement { + fn drop(&mut self) { + if self.settled { + return; + } + get_user_input_manager().cancel_registration(&self.pending); + if let Some(coordinator) = self.coordinator.as_ref() { + coordinator.resolve_pending_user_input_now(&self.pending); + } + } +} + /// AskUserQuestion tool pub struct AskUserQuestionTool; @@ -210,15 +250,36 @@ Usage notes: let (tx, rx) = tokio::sync::oneshot::channel(); // 5. Register to global manager - let manager = get_user_input_manager(); - manager.register_channel(tool_id.clone(), tx); - - // 6. Send backend event to notify frontend to display question card - let event_system = get_global_event_system(); let session_id = context .session_id .clone() .unwrap_or_else(|| "unknown".to_string()); + let turn_id = context + .dialog_turn_id + .clone() + .unwrap_or_else(|| "unknown".to_string()); + let pending = PendingUserInput { + tool_id: tool_id.clone(), + session_id: session_id.clone(), + turn_id: turn_id.clone(), + source_session_id: session_id.clone(), + source_turn_id: turn_id, + registration_sequence: 0, + input: serde_json::to_value(&tool_input) + .expect("validated AskUserQuestion input must serialize"), + }; + let coordinator = get_global_coordinator(); + let pending = if let Some(coordinator) = coordinator.as_ref() { + coordinator.register_pending_user_input(pending, tx).await + } else { + let mut pending = pending; + pending.registration_sequence = get_user_input_manager().register(pending.clone(), tx); + pending + }; + let settlement = PendingUserInputSettlement::new(pending, coordinator.clone()); + + // 6. Send backend event to notify frontend to display question card + let event_system = get_global_event_system(); // Send complete questions array to frontend let event = BackendEvent::ToolAwaitingUserInput { @@ -234,7 +295,9 @@ Usage notes: ); // 7. Wait for user answer until the user responds, cancels, or the turn is cancelled. - match rx.await { + let response = rx.await; + settlement.settle().await; + match response { Ok(response) => { debug!( "AskUserQuestion tool received user response, tool_id: {}", @@ -344,6 +407,108 @@ mod tests { assert!(error.to_string().contains("cannot accept interactive")); } + #[tokio::test] + async fn ask_user_question_registers_authoritative_restore_facts_until_answered() { + let tool_id = format!("question-{}", uuid::Uuid::new_v4()); + let mut context = context_with_custom_data(HashMap::new()); + context.tool_call_id = Some(tool_id.clone()); + context.session_id = Some("child-session".to_string()); + context.dialog_turn_id = Some("child-turn".to_string()); + let input = serde_json::json!({ + "questions": [{ + "question": "Continue?", + "header": "Continue", + "options": [ + { "label": "Yes", "description": "Continue" }, + { "label": "No", "description": "Stop" } + ] + }] + }); + let task_tool_id = tool_id.clone(); + let task = + tokio::spawn(async move { AskUserQuestionTool::new().call(&input, &context).await }); + let manager = crate::agentic::tools::user_input_manager::get_user_input_manager(); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while !manager.has_pending(&tool_id) { + tokio::task::yield_now().await; + } + }) + .await + .expect("question registration deadline"); + + let pending = manager + .pending_inputs() + .into_iter() + .find(|pending| pending.tool_id == tool_id) + .expect("pending restore fact"); + assert_eq!(pending.session_id, "child-session"); + assert_eq!(pending.turn_id, "child-turn"); + assert_eq!(pending.source_session_id, "child-session"); + assert_eq!(pending.source_turn_id, "child-turn"); + assert_eq!(pending.input["questions"][0]["header"], "Continue"); + + manager + .send_answer(bitfun_runtime_ports::AgentUserAnswersRequest { + session_id: pending.session_id, + turn_id: pending.turn_id, + tool_id: pending.tool_id, + registration_sequence: pending.registration_sequence, + answers: serde_json::json!({ "0": "Yes" }), + }) + .expect("deliver answer"); + task.await + .expect("question task") + .expect("answered question result"); + assert!(!manager.has_pending(&task_tool_id)); + } + + #[tokio::test] + async fn cancelling_ask_user_question_settles_the_exact_pending_registration() { + let tool_id = format!("question-cancel-{}", uuid::Uuid::new_v4()); + let cancellation = tokio_util::sync::CancellationToken::new(); + let mut context = context_with_custom_data(HashMap::new()); + context.tool_call_id = Some(tool_id.clone()); + context.session_id = Some("cancel-session".to_string()); + context.dialog_turn_id = Some("cancel-turn".to_string()); + context.runtime_handles = + bitfun_runtime_ports::ToolRuntimeHandles::new(None, Some(cancellation.clone())); + let input = serde_json::json!({ + "questions": [{ + "question": "Continue?", + "header": "Continue", + "options": [ + { "label": "Yes", "description": "Continue" }, + { "label": "No", "description": "Stop" } + ] + }] + }); + let task = + tokio::spawn(async move { AskUserQuestionTool::new().call(&input, &context).await }); + let manager = crate::agentic::tools::user_input_manager::get_user_input_manager(); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while !manager.has_pending(&tool_id) { + tokio::task::yield_now().await; + } + }) + .await + .expect("question registration deadline"); + + cancellation.cancel(); + let error = task + .await + .expect("question task") + .expect_err("cancelled question must stop the tool call"); + + assert!(matches!( + error, + crate::util::errors::BitFunError::Cancelled(_) + )); + assert!(!manager + .pending_inputs() + .iter() + .any(|pending| pending.tool_id == tool_id)); + } + #[test] fn ask_user_question_schema_defaults_multi_select_to_false() { let schema = AskUserQuestionTool::new().input_schema(); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs index 3212d395ec..aa1c2e8cd6 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs @@ -181,6 +181,20 @@ pub async fn save_project_mode_skills_document_local( save_project_agent_profiles_document_local(workspace_root, document).await } +/// Updates one project-scoped mode Skill and persists the owning document. +pub async fn set_project_mode_skill_state_local( + workspace_root: &Path, + mode_id: &str, + skill_key: &str, + enabled: bool, +) -> BitFunResult> { + let mut document = load_project_agent_profiles_document_local(workspace_root).await?; + let disabled = + set_mode_skill_disabled_in_document(&mut document, mode_id, skill_key, !enabled)?; + save_project_agent_profiles_document_local(workspace_root, &document).await?; + Ok(disabled) +} + pub async fn load_disabled_mode_skills_local( workspace_root: &Path, mode_id: &str, @@ -215,3 +229,43 @@ pub async fn load_disabled_mode_skills_remote( &resolve_profile_id(mode_id), )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn project_mode_skill_state_is_mutated_and_persisted_by_the_owner() { + let workspace = tempfile::tempdir().expect("workspace"); + + let disabled = set_project_mode_skill_state_local( + workspace.path(), + "code", + "skill/project-review", + false, + ) + .await + .expect("disable project skill"); + assert_eq!(disabled, ["skill/project-review"]); + assert_eq!( + load_disabled_mode_skills_local(workspace.path(), "code") + .await + .expect("reload disabled skills"), + ["skill/project-review"] + ); + + let disabled = set_project_mode_skill_state_local( + workspace.path(), + "code", + "skill/project-review", + true, + ) + .await + .expect("enable project skill"); + assert!(disabled.is_empty()); + assert!(load_disabled_mode_skills_local(workspace.path(), "code") + .await + .expect("reload enabled skill") + .is_empty()); + } +} diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 791ee9da04..ad7e6e1ea1 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -6462,6 +6462,149 @@ pub async fn external_source_conflict_choices() -> Result< )) } +/// Owner-level mutation requested by an interactive external-source review +/// surface. Hosts translate their wire or presentation DTO into this enum; +/// the mutation dispatch itself remains single-sourced in Product Assembly. +#[derive(Debug, Clone)] +pub enum ExternalSourceReviewAction { + Refresh, + SetPromptCommandConflictChoice { + conflict_key: String, + candidate_id: String, + expected_preference_revision: u64, + }, + SetToolTargetDecision { + approval_key: String, + decision_key: String, + approved: bool, + expected_preference_revision: u64, + }, + SetToolConflictChoice { + conflict_key: String, + candidate_id: String, + expected_preference_revision: u64, + }, + SetSubagentActivation { + candidate_id: String, + approved: bool, + expected_subagent_generation: u64, + expected_preference_revision: u64, + decision_key: String, + }, + SetSubagentModelBinding { + binding_key: String, + target: Option, + expected_subagent_generation: u64, + expected_preference_revision: u64, + }, + ChooseSubagentConflict { + conflict_key: String, + candidate_id: String, + approve_external: bool, + expected_subagent_generation: u64, + expected_preference_revision: u64, + }, +} + +pub async fn review_external_source( + workspace_root: Option<&Path>, + action: ExternalSourceReviewAction, +) -> Result { + match action { + ExternalSourceReviewAction::Refresh => external_source_snapshot(workspace_root, true).await, + ExternalSourceReviewAction::SetPromptCommandConflictChoice { + conflict_key, + candidate_id, + expected_preference_revision, + } => { + set_external_prompt_command_conflict_choice( + workspace_root, + &conflict_key, + &candidate_id, + expected_preference_revision, + ) + .await + } + ExternalSourceReviewAction::SetToolTargetDecision { + approval_key, + decision_key, + approved, + expected_preference_revision, + } => { + set_external_tool_target_decision( + workspace_root, + &approval_key, + &decision_key, + approved, + expected_preference_revision, + ) + .await + } + ExternalSourceReviewAction::SetToolConflictChoice { + conflict_key, + candidate_id, + expected_preference_revision, + } => { + set_external_tool_conflict_choice( + workspace_root, + &conflict_key, + &candidate_id, + expected_preference_revision, + ) + .await + } + ExternalSourceReviewAction::SetSubagentActivation { + candidate_id, + approved, + expected_subagent_generation, + expected_preference_revision, + decision_key, + } => { + set_external_subagent_activation( + workspace_root, + &candidate_id, + approved, + expected_subagent_generation, + expected_preference_revision, + &decision_key, + ) + .await + } + ExternalSourceReviewAction::SetSubagentModelBinding { + binding_key, + target, + expected_subagent_generation, + expected_preference_revision, + } => { + set_external_subagent_model_binding( + workspace_root, + &binding_key, + target, + expected_subagent_generation, + expected_preference_revision, + ) + .await + } + ExternalSourceReviewAction::ChooseSubagentConflict { + conflict_key, + candidate_id, + approve_external, + expected_subagent_generation, + expected_preference_revision, + } => { + choose_external_subagent_conflict( + workspace_root, + &conflict_key, + &candidate_id, + approve_external, + expected_subagent_generation, + expected_preference_revision, + ) + .await + } + } +} + async fn remember_native_prompt_command_conflict_choice( conflict_key: &str, candidate_id: &str, diff --git a/src/crates/assembly/core/src/native_hooks.rs b/src/crates/assembly/core/src/native_hooks.rs index 692e706d61..25569a9fd2 100644 --- a/src/crates/assembly/core/src/native_hooks.rs +++ b/src/crates/assembly/core/src/native_hooks.rs @@ -705,6 +705,199 @@ pub struct NativeHookOverview { pub issues: Vec, } +#[derive(Debug, Clone)] +pub struct NativeHookManagementHandler { + pub command_summary: String, + pub command_truncated: bool, + pub timeout_seconds: u64, + pub status_message: Option, +} + +#[derive(Debug, Clone)] +pub struct NativeHookManagementRule { + pub event: String, + pub matcher: String, + pub matcher_is_valid: bool, + pub scope: String, + pub handlers: Vec, +} + +#[derive(Debug, Clone)] +pub struct NativeHookManagementFile { + pub scope: String, + pub location: String, + pub exists: bool, + pub loaded: bool, +} + +/// Host-safe read model. Filesystem paths and unbounded command text are +/// redacted here once so every management surface follows the same policy. +#[derive(Debug, Clone)] +pub struct NativeHookManagementOverview { + pub enabled: bool, + pub project_hooks_enabled: bool, + pub files: Vec, + pub rules: Vec, + pub total_handlers: usize, + pub issues: Vec, +} + +const MAX_NATIVE_HOOK_COMMAND_CHARS: usize = 200; +const MAX_NATIVE_HOOK_STATUS_CHARS: usize = 200; + +fn bounded_management_text(value: &str, max_chars: usize) -> (String, bool) { + let value = value.trim(); + let truncated = value.chars().count() > max_chars; + let mut summary = value + .chars() + .take(max_chars) + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect::(); + if truncated { + summary.push_str("..."); + } + (summary, truncated) +} + +fn managed_hook_location(path: &Path, workspace: &Path, user_hooks_file: Option<&Path>) -> String { + if user_hooks_file.is_some_and(|user| user == path) { + return "/config/hooks.json".to_string(); + } + if let Ok(relative) = path.strip_prefix(workspace) { + let relative = relative.to_string_lossy().replace('\\', "/"); + return if relative.is_empty() { + "".to_string() + } else { + format!("/{relative}") + }; + } + let import_id = path + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .map(|value| value.to_string_lossy()) + .map(|value| { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + character + } else { + '_' + } + }) + .collect::() + }) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "import".to_string()); + format!("/{import_id}/hooks.json") +} + +pub async fn overview_for_management(workspace: &Path) -> NativeHookManagementOverview { + let overview = overview(Some(workspace)).await; + let user_hooks_file = try_get_path_manager_arc() + .ok() + .map(|manager| manager.user_hooks_file()); + let path_labels = overview + .files + .iter() + .map(|file| { + ( + file.path.clone(), + managed_hook_location(&file.path, workspace, user_hooks_file.as_deref()), + ) + }) + .collect::>(); + let sanitize_issue = |issue: String| { + path_labels.iter().fold(issue, |sanitized, (path, label)| { + let native = path.to_string_lossy(); + let sanitized = sanitized.replace(native.as_ref(), label); + sanitized.replace(&native.replace('\\', "/"), label) + }) + }; + NativeHookManagementOverview { + enabled: overview.enabled, + project_hooks_enabled: overview.project_hooks_enabled, + files: overview + .files + .into_iter() + .zip(path_labels.iter()) + .map(|(file, (_, location))| NativeHookManagementFile { + scope: file.scope.to_string(), + location: location.clone(), + exists: file.exists, + loaded: file.loaded, + }) + .collect(), + rules: overview + .rules + .into_iter() + .map(|rule| NativeHookManagementRule { + event: rule.event.to_string(), + matcher: rule.matcher, + matcher_is_valid: rule.matcher_is_valid, + scope: rule.scope.to_string(), + handlers: rule + .handlers + .into_iter() + .map(|handler| { + let (command_summary, command_truncated) = bounded_management_text( + &handler.command, + MAX_NATIVE_HOOK_COMMAND_CHARS, + ); + NativeHookManagementHandler { + command_summary, + command_truncated, + timeout_seconds: handler.timeout_seconds, + status_message: handler.status_message.map(|message| { + bounded_management_text(&message, MAX_NATIVE_HOOK_STATUS_CHARS).0 + }), + } + }) + .collect(), + }) + .collect(), + total_handlers: overview.total_handlers, + issues: overview.issues.into_iter().map(sanitize_issue).collect(), + } +} + +#[cfg(test)] +mod management_projection_tests { + use super::{bounded_management_text, managed_hook_location}; + use std::path::Path; + + #[test] + fn management_projection_bounds_commands_and_hides_host_paths() { + let (summary, truncated) = + bounded_management_text(&format!("secret\n{}", "x".repeat(240)), 200); + assert!(truncated); + assert_eq!(summary.chars().count(), 203); + assert!(!summary.contains('\n')); + + assert_eq!( + managed_hook_location( + Path::new("D:/secret/project/.bitfun/config/hooks.json"), + Path::new("D:/secret/project"), + None, + ), + "/.bitfun/config/hooks.json" + ); + assert!(!managed_hook_location( + Path::new("C:/private/runtime/hook-imports/bundles/import-one/version/hooks.json"), + Path::new("D:/secret/project"), + None, + ) + .contains("C:/private")); + } +} + /// Read the hook configuration for a workspace without dispatching anything. /// /// This is the read-only view behind the CLI `/hooks` command and any other diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 878c1d1efe..79befbe706 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -19,7 +19,7 @@ use bitfun_agent_runtime::sdk::{ AgentSessionLineageEntry, AgentSessionLineageInspection, AgentSessionLineagePort, AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionLineageTranscriptRequest, AgentSessionUsagePort, AgentSessionUsageRequest, AgentTurnCancellationResult, - AgentTurnSettlementPort, AgentTurnSettlementRequest, SessionTranscript, + AgentTurnSettlementPort, AgentTurnSettlementRequest, PendingUserInput, SessionTranscript, }; use bitfun_core_types::{SESSION_PROVIDER_ACP, SESSION_PROVIDER_METADATA_KEY}; use bitfun_harness::HarnessRegistry; @@ -984,6 +984,16 @@ impl CoreAgentRuntimeCompatibility { .get_session(session_id)) } + /// Return the authoritative pending user-input registrations projected to + /// the requested root session by the coordinator that owns execution. + pub fn pending_user_inputs_for_session( + &self, + session_id: &str, + ) -> BitFunResult> { + validate_persisted_session_id(session_id)?; + Ok(self.coordinator.pending_user_inputs_for_session(session_id)) + } + pub async fn update_loaded_session_title( &self, session_id: &str, @@ -1327,12 +1337,7 @@ impl AgentContextReloadPort for CoreAgentRuntimeCompatibility { ) -> bitfun_runtime_ports::PortResult<()> { CoreAgentRuntimeCompatibility::reload_session_context(self, request) .await - .map_err(|error| { - bitfun_runtime_ports::PortError::new( - bitfun_runtime_ports::PortErrorKind::Backend, - error.to_string(), - ) - }) + .map_err(runtime_port_error) } } @@ -2427,6 +2432,7 @@ mod tests { let _ = CoreAgentRuntimeCompatibility::list_persisted_sessions; let _ = CoreAgentRuntimeCompatibility::load_persisted_session_turns; let _ = CoreAgentRuntimeCompatibility::loaded_session_snapshot; + let _ = CoreAgentRuntimeCompatibility::pending_user_inputs_for_session; let _ = CoreAgentRuntimeCompatibility::reload_session_context; let _ = CoreAgentRuntimeCompatibility::unload_persisted_session; } @@ -2522,6 +2528,17 @@ mod tests { .await .expect_err("missing session must be rejected before refreshing skills"); assert!(error.to_string().contains(missing_id), "{error}"); + + let port_error = bitfun_runtime_ports::AgentContextReloadPort::reload_session_context( + &compatibility, + AgentContextReloadRequest { + session_id: missing_id.to_string(), + target: AgentContextReloadTarget::Skills, + }, + ) + .await + .expect_err("runtime port must preserve the missing-session classification"); + assert_eq!(port_error.kind, PortErrorKind::NotFound); } #[test] diff --git a/src/crates/assembly/core/src/service/config/mod.rs b/src/crates/assembly/core/src/service/config/mod.rs index 4fb9ccaf25..37708638b6 100644 --- a/src/crates/assembly/core/src/service/config/mod.rs +++ b/src/crates/assembly/core/src/service/config/mod.rs @@ -10,6 +10,7 @@ pub mod global; pub mod manager; #[cfg(feature = "agent-runtime")] pub mod mode_config_canonicalizer; +pub mod model_management; pub mod normalization; pub mod project_permission_store; pub mod providers; @@ -30,6 +31,7 @@ pub use mode_config_canonicalizer::{ canonicalize_agent_profile_configs, AgentProfileConfigCanonicalizationReport, AgentProfileConfigUpdateInfo, }; +pub use model_management::*; pub use normalization::{ isolate_invalid_ai_models, normalize_config_value, normalize_typed_config, reconcile_model_references, reject_unsupported_schema, ConfigNormalizationResult, diff --git a/src/crates/assembly/core/src/service/config/model_management.rs b/src/crates/assembly/core/src/service/config/model_management.rs new file mode 100644 index 0000000000..bcbe34adaf --- /dev/null +++ b/src/crates/assembly/core/src/service/config/model_management.rs @@ -0,0 +1,314 @@ +//! Secret-safe model management use cases owned by the configuration domain. + +use std::collections::HashMap; + +use super::{AIConfig, AIModelConfig, AuthConfig, ConfigService, GlobalConfig, ReasoningConfig}; +use crate::{BitFunError, BitFunResult}; + +#[derive(Clone)] +pub enum SecretUpdate { + Preserve, + Replace(T), + Clear, +} + +impl std::fmt::Debug for SecretUpdate { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Preserve => "Preserve", + Self::Replace(_) => "Replace()", + Self::Clear => "Clear", + }) + } +} + +#[derive(Clone, Debug)] +pub struct AIModelManagementMutation { + pub id: String, + pub name: String, + pub provider: String, + pub model_name: String, + pub base_url: String, + pub api_key: Option>, + pub custom_headers: Option>>, + pub custom_request_body: Option>, + pub context_window: Option, + pub max_tokens: Option, + pub enabled: bool, + pub reasoning: Option, + pub inline_think_in_text: bool, + pub skip_ssl_verify: bool, + pub custom_headers_mode: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AIModelManagementSummary { + pub id: String, + pub name: String, + pub provider: String, + pub model_name: String, + pub base_url: String, + pub enabled: bool, + pub context_window: Option, + pub max_tokens: Option, + pub api_key_configured: bool, + pub custom_header_names: Vec, + pub custom_request_body_configured: bool, + pub auth_source: String, +} + +#[derive(Clone, Debug)] +pub struct AIModelManagementProjection { + pub summary: AIModelManagementSummary, + pub reasoning_preset_options: Vec, + pub reasoning: Option, + pub inline_think_in_text: bool, + pub skip_ssl_verify: bool, + pub custom_headers_mode: String, +} + +#[derive(Clone, Debug)] +pub struct AIModelManagementCatalog { + pub models: Vec, + pub primary_model_id: Option, + pub fast_model_id: Option, + pub mode_default_model_id: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AIModelDefaultSlot { + Primary, + Mode, +} + +impl AIModelManagementSummary { + fn from_model(model: &AIModelConfig) -> Self { + let mut custom_header_names = model + .custom_headers + .as_ref() + .map(|headers| headers.keys().cloned().collect::>()) + .unwrap_or_default(); + custom_header_names.sort(); + Self { + id: model.id.clone(), + name: model.name.clone(), + provider: model.provider.clone(), + model_name: model.model_name.clone(), + base_url: model.base_url.clone(), + enabled: model.enabled, + context_window: model.context_window, + max_tokens: model.max_tokens, + api_key_configured: !model.api_key.is_empty(), + custom_header_names, + custom_request_body_configured: model.custom_request_body.is_some(), + auth_source: match &model.auth { + AuthConfig::ApiKey => "api_key".to_string(), + AuthConfig::Subscription { provider, .. } => { + format!("subscription:{provider:?}").to_ascii_lowercase() + } + }, + } + } +} + +impl AIModelManagementProjection { + fn from_model(model: &AIModelConfig) -> Self { + Self { + summary: AIModelManagementSummary::from_model(model), + reasoning_preset_options: model + .reasoning + .as_ref() + .map(|reasoning| { + reasoning + .presets + .iter() + .map(|preset| preset.id.clone()) + .collect() + }) + .unwrap_or_default(), + reasoning: model.reasoning.clone(), + inline_think_in_text: model.inline_think_in_text, + skip_ssl_verify: model.skip_ssl_verify, + custom_headers_mode: model + .custom_headers_mode + .clone() + .unwrap_or_else(|| "merge".to_string()), + } + } +} + +impl AIModelManagementMutation { + fn into_model(self, existing: Option) -> AIModelConfig { + let current = existing.unwrap_or_default(); + AIModelConfig { + id: self.id, + name: self.name, + provider: self.provider, + model_name: self.model_name, + base_url: self.base_url, + request_url: current.request_url, + api_key: apply_required_secret(self.api_key, current.api_key), + context_window: self.context_window, + max_tokens: self.max_tokens, + temperature: current.temperature, + top_p: current.top_p, + enabled: self.enabled, + category: current.category, + capabilities: current.capabilities, + recommended_for: current.recommended_for, + metadata: current.metadata, + reasoning: self.reasoning, + inline_think_in_text: self.inline_think_in_text, + custom_headers: apply_optional_secret(self.custom_headers, current.custom_headers), + custom_headers_mode: self.custom_headers_mode.or(current.custom_headers_mode), + skip_ssl_verify: self.skip_ssl_verify, + custom_request_body: apply_optional_string_secret( + self.custom_request_body, + current.custom_request_body, + ), + custom_request_body_mode: current.custom_request_body_mode, + auth: current.auth, + } + } +} + +fn apply_required_secret(update: Option>, existing: String) -> String { + match update.unwrap_or(SecretUpdate::Preserve) { + SecretUpdate::Preserve => existing, + SecretUpdate::Replace(value) => value, + SecretUpdate::Clear => String::new(), + } +} + +fn apply_optional_secret(update: Option>, existing: Option) -> Option { + match update.unwrap_or(SecretUpdate::Preserve) { + SecretUpdate::Preserve => existing, + SecretUpdate::Replace(value) => Some(value), + SecretUpdate::Clear => None, + } +} + +fn apply_optional_string_secret( + update: Option>, + existing: Option, +) -> Option { + match update.unwrap_or(SecretUpdate::Preserve) { + SecretUpdate::Preserve => existing, + SecretUpdate::Replace(value) => (!value.is_empty()).then_some(value), + SecretUpdate::Clear => None, + } +} + +fn resolve_selector(ai: &AIConfig, selector: &Option) -> Option { + selector + .as_deref() + .and_then(|selector| ai.resolve_model_selection(selector)) +} + +fn resolve_mode_selector(ai: &AIConfig, selector: &str) -> Option { + match selector.trim() { + "" | "auto" | "default" => ai.resolve_model_selection("primary"), + selector => ai.resolve_model_selection(selector), + } +} + +fn selector_is_unset(selector: &Option) -> bool { + selector + .as_deref() + .is_none_or(|selector| selector.trim().is_empty()) +} + +impl ConfigService { + pub async fn list_ai_models_for_management(&self) -> BitFunResult { + let config: GlobalConfig = self.get_config(None).await?; + Ok(AIModelManagementCatalog { + models: config + .ai + .models + .iter() + .map(AIModelManagementSummary::from_model) + .collect(), + primary_model_id: resolve_selector(&config.ai, &config.ai.default_models.primary), + fast_model_id: resolve_selector(&config.ai, &config.ai.default_models.fast), + mode_default_model_id: resolve_mode_selector( + &config.ai, + &config.ai.agent_model_defaults.mode, + ), + }) + } + + pub async fn get_ai_model_for_management( + &self, + model_id: &str, + ) -> BitFunResult { + let model = self + .get_ai_models() + .await? + .into_iter() + .find(|model| model.id == model_id) + .ok_or_else(|| BitFunError::NotFound(format!("AI model '{model_id}' was not found")))?; + Ok(AIModelManagementProjection::from_model(&model)) + } + + pub async fn add_ai_model_for_management( + &self, + mutation: AIModelManagementMutation, + make_primary_if_empty: bool, + ) -> BitFunResult<()> { + let model = mutation.into_model(None); + let model_id = model.id.clone(); + self.add_ai_model(model).await?; + if make_primary_if_empty { + let config: GlobalConfig = self.get_config(None).await?; + if selector_is_unset(&config.ai.default_models.primary) { + self.set_config("ai.default_models.primary", &Some(model_id)) + .await?; + } + } + Ok(()) + } + + pub async fn update_ai_model_for_management( + &self, + model_id: &str, + mutation: AIModelManagementMutation, + ) -> BitFunResult<()> { + if mutation.id != model_id { + return Err(BitFunError::validation( + "Model update identity does not match the request target", + )); + } + let existing = self + .get_ai_models() + .await? + .into_iter() + .find(|model| model.id == model_id) + .ok_or_else(|| BitFunError::NotFound(format!("AI model '{model_id}' was not found")))?; + self.update_ai_model(model_id, mutation.into_model(Some(existing))) + .await + } + + pub async fn delete_ai_model_for_management(&self, model_id: &str) -> BitFunResult<()> { + self.delete_ai_model(model_id).await + } + + pub async fn set_ai_model_default_for_management( + &self, + slot: AIModelDefaultSlot, + model_id: Option, + ) -> BitFunResult<()> { + match slot { + AIModelDefaultSlot::Primary => { + self.set_config("ai.default_models.primary", &model_id) + .await + } + AIModelDefaultSlot::Mode => { + self.set_config( + "ai.agent_model_defaults.mode", + model_id.as_deref().unwrap_or("auto"), + ) + .await + } + } + } +} diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 8414f8af99..91953fb5bd 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -3,6 +3,8 @@ //! Provides comprehensive configuration management functionality. use super::manager::{ConfigManager, ConfigManagerSettings, ConfigStatistics}; +#[cfg(test)] +use super::model_management::*; use super::types::*; use crate::util::errors::*; use log::{info, warn}; @@ -663,6 +665,77 @@ mod tests { assert!(current["mcpServers"].get("stale").is_none()); } + #[tokio::test] + async fn model_management_update_preserves_write_only_values_and_returns_safe_projection() { + let (service, _dir) = test_service("model-management-secrets").await; + service + .add_ai_model(AIModelConfig { + id: "model-1".to_string(), + name: "Original".to_string(), + provider: "openai".to_string(), + model_name: "gpt-original".to_string(), + base_url: "https://example.com/v1".to_string(), + api_key: "existing-key".to_string(), + custom_headers: Some(HashMap::from([ + ("Z-Header".to_string(), "secret-z".to_string()), + ("A-Header".to_string(), "secret-a".to_string()), + ])), + custom_request_body: Some("existing-body".to_string()), + enabled: true, + ..Default::default() + }) + .await + .expect("seed model"); + + service + .update_ai_model_for_management( + "model-1", + AIModelManagementMutation { + id: "model-1".to_string(), + name: "Updated".to_string(), + provider: "openai".to_string(), + model_name: "gpt-updated".to_string(), + base_url: "https://example.com/v2".to_string(), + api_key: None, + custom_headers: None, + custom_request_body: Some(SecretUpdate::Clear), + context_window: Some(128_000), + max_tokens: Some(8_192), + enabled: true, + reasoning: None, + inline_think_in_text: true, + skip_ssl_verify: false, + custom_headers_mode: Some("replace".to_string()), + }, + ) + .await + .expect("update model through owner API"); + + let projection = service + .get_ai_model_for_management("model-1") + .await + .expect("safe model projection"); + assert_eq!(projection.summary.name, "Updated"); + assert!(projection.summary.api_key_configured); + assert_eq!( + projection.summary.custom_header_names, + ["A-Header", "Z-Header"] + ); + assert!(!projection.summary.custom_request_body_configured); + assert_eq!(projection.custom_headers_mode, "replace"); + + let stored = service + .get_ai_models() + .await + .expect("stored models") + .into_iter() + .find(|model| model.id == "model-1") + .expect("updated model"); + assert_eq!(stored.api_key, "existing-key"); + assert_eq!(stored.custom_headers.unwrap()["A-Header"], "secret-a"); + assert!(stored.custom_request_body.is_none()); + } + #[tokio::test] async fn startup_repairs_speech_sentinels_and_creates_a_backup() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/crates/assembly/core/src/service/mcp/management.rs b/src/crates/assembly/core/src/service/mcp/management.rs new file mode 100644 index 0000000000..a1ba6d9fce --- /dev/null +++ b/src/crates/assembly/core/src/service/mcp/management.rs @@ -0,0 +1,549 @@ +//! Product-facing MCP management use cases. +//! +//! This module owns MCP configuration/runtime projections without depending on +//! App Server wire types or a concrete product surface. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use bitfun_product_domains::external_sources::{ + ExternalMcpActivationState, ExternalMcpCatalogEntry, ExternalMcpTransportKind, + ExternalSourceCatalogSnapshot, +}; + +use super::{ + ConfigLocation, MCPServerConfig, MCPServerManager, MCPServerStatus, MCPServerTransport, + MCPServerType, MCPService, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpManagementErrorKind { + InvalidRequest, + Unavailable, + Internal, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpManagementError { + pub kind: McpManagementErrorKind, + pub message: String, +} + +impl McpManagementError { + fn invalid(message: impl Into) -> Self { + Self { + kind: McpManagementErrorKind::InvalidRequest, + message: message.into(), + } + } + + fn internal(message: impl Into) -> Self { + Self { + kind: McpManagementErrorKind::Internal, + message: sanitize_error(message.into()), + } + } +} + +impl std::fmt::Display for McpManagementError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for McpManagementError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpManagementList { + pub servers: Vec, + pub config_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpManagementServer { + pub id: String, + pub name: String, + pub server_type: String, + pub status: String, + pub tool_count: usize, + pub source_label: String, + pub external: bool, + pub detail: String, + pub action: McpManagementAction, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum McpManagementAction { + NativeToggle, + ReadOnly { + reason: String, + }, + ExternalDecision { + candidate_id: String, + decision_key: String, + approved: bool, + expected_mcp_generation: u64, + expected_preference_revision: u64, + }, + ConflictChoice { + conflict_key: String, + candidate_id: String, + approve_external: bool, + expected_mcp_generation: u64, + expected_preference_revision: u64, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpManagementTransport { + Stdio, + Sse, + StreamableHttp, +} + +#[derive(Clone)] +pub struct McpManagementMutation { + pub transport: McpManagementTransport, + pub command: Option, + pub args: Vec, + pub env: HashMap, + pub headers: HashMap, + pub url: Option, + pub auto_start: bool, + pub enabled: bool, + pub oauth: Option, + pub xaa: Option, +} + +impl std::fmt::Debug for McpManagementMutation { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("McpManagementMutation") + .field("transport", &self.transport) + .field("command_configured", &self.command.is_some()) + .field("argument_count", &self.args.len()) + .field("environment_keys", &self.env.keys().collect::>()) + .field("header_names", &self.headers.keys().collect::>()) + .field("url_configured", &self.url.is_some()) + .field("auto_start", &self.auto_start) + .field("enabled", &self.enabled) + .field("oauth_configured", &self.oauth.is_some()) + .field("xaa_configured", &self.xaa.is_some()) + .finish() + } +} + +#[derive(Clone)] +pub struct McpManagementService { + service: Arc, +} + +impl McpManagementService { + pub fn new(service: Arc) -> Self { + Self { service } + } + + pub async fn list(&self, workspace: &Path) -> Result { + let external = crate::external_sources::external_source_snapshot(Some(workspace), false) + .await + .map_err(McpManagementError::internal)?; + let tool_registry = crate::agentic::tools::registry::get_global_tool_registry(); + let tools = tool_registry.read().await.get_all_tools(); + let configs = self + .service + .config_service() + .load_all_configs() + .await + .map_err(|error| McpManagementError::internal(error.to_string()))?; + let manager = self.service.server_manager(); + let mut servers = Vec::new(); + + for config in configs { + let status = native_status(&config, manager.as_ref()).await; + let prefix = format!("mcp_{}_", config.id); + let tool_count = tools + .iter() + .filter(|tool| tool.name().starts_with(&prefix)) + .count(); + let native_id = crate::external_sources::native_mcp_candidate_id(&config.id); + let conflict = external.mcp_conflicts.iter().find(|conflict| { + conflict + .candidates + .iter() + .any(|candidate| candidate.candidate_id == native_id) + }); + let action = match conflict { + Some(conflict) + if conflict + .candidates + .iter() + .find(|candidate| candidate.candidate_id == native_id) + .is_some_and(|candidate| !candidate.available) => + { + let reason = conflict + .candidates + .iter() + .find(|candidate| candidate.candidate_id == native_id) + .and_then(|candidate| candidate.unavailable_reason.clone()) + .unwrap_or_else(|| { + "Enable this BitFun server in its MCP configuration".to_string() + }); + McpManagementAction::ReadOnly { reason } + } + Some(conflict) if conflict.selected_candidate_id.as_deref() != Some(&native_id) => { + McpManagementAction::ConflictChoice { + conflict_key: conflict.conflict_key.clone(), + candidate_id: native_id, + approve_external: false, + expected_mcp_generation: external.mcp_generation, + expected_preference_revision: external.preference_revision, + } + } + _ => McpManagementAction::NativeToggle, + }; + servers.push(McpManagementServer { + id: config.id.clone(), + name: config.name.clone(), + server_type: format!("{:?}", config.server_type).to_lowercase(), + status, + tool_count, + source_label: "BitFun".to_string(), + external: false, + detail: native_detail(&config), + action, + }); + } + + for entry in &external.mcp_servers { + let source_label = external + .sources + .iter() + .find(|source| source.record.key == entry.definition.id.source) + .map(|source| source.record.display_name.clone()) + .unwrap_or_else(|| "External AI app".to_string()); + let status = external_status(entry, manager.as_ref()).await; + let tool_count = entry.runtime_id.as_deref().map_or(0, |runtime_id| { + let prefix = format!("mcp_{runtime_id}_"); + tools + .iter() + .filter(|tool| tool.name().starts_with(&prefix)) + .count() + }); + servers.push(McpManagementServer { + id: entry.candidate_id.clone(), + name: entry.definition.name.clone(), + server_type: "external".to_string(), + status, + tool_count, + source_label, + external: true, + detail: external_detail(entry), + action: external_action(entry, &external), + }); + } + + if servers.is_empty() && external.discovery_pending { + servers.push(McpManagementServer { + id: "external-mcp-discovery-pending".to_string(), + name: "External MCP servers".to_string(), + server_type: "external".to_string(), + status: "Checking".to_string(), + tool_count: 0, + source_label: "External AI applications".to_string(), + external: true, + detail: "BitFun is still checking compatible MCP settings".to_string(), + action: McpManagementAction::ReadOnly { + reason: "Still checking; this list updates automatically".to_string(), + }, + }); + } + + let config_path = crate::infrastructure::try_get_path_manager_arc() + .ok() + .map(|manager| manager.app_config_file().display().to_string()); + Ok(McpManagementList { + servers, + config_path, + }) + } + + pub async fn toggle(&self, server_id: &str) -> Result<(), McpManagementError> { + let manager = self.service.server_manager(); + match manager.get_server_status(server_id).await { + Ok(MCPServerStatus::Connected) | Ok(MCPServerStatus::Healthy) => { + manager.stop_server(server_id).await + } + _ => manager.start_server(server_id).await, + } + .map_err(|error| McpManagementError::internal(error.to_string())) + } + + pub async fn add( + &self, + name: &str, + mutation: McpManagementMutation, + ) -> Result<(), McpManagementError> { + let config = config_from_mutation(name, mutation)?; + self.service + .server_manager() + .add_server(config) + .await + .map_err(|error| McpManagementError::internal(error.to_string())) + } + + pub async fn delete(&self, server_id: &str) -> Result<(), McpManagementError> { + self.service + .config_service() + .delete_server_config(server_id) + .await + .map_err(|error| McpManagementError::internal(error.to_string()))?; + schedule_stop(self.service.server_manager(), server_id.to_string()); + Ok(()) + } +} + +async fn native_status(config: &MCPServerConfig, manager: &MCPServerManager) -> String { + if !config.enabled { + return "Stopped".to_string(); + } + match tokio::time::timeout( + Duration::from_millis(30), + manager.get_server_status(&config.id), + ) + .await + { + Ok(Ok(value)) => format!("{value:?}"), + _ => "Starting".to_string(), + } +} + +fn native_detail(config: &MCPServerConfig) -> String { + let server_type = format!("{:?}", config.server_type).to_ascii_lowercase(); + let transport = config.resolved_transport().as_str(); + if config.server_type == MCPServerType::Local { + format!( + "type: {server_type}; transport: {transport}; command: {}; arguments: {}; environment variables set: {}", + config.command.as_deref().unwrap_or("unknown"), + config.args.len(), + if config.env.is_empty() { "none" } else { "configured" } + ) + } else { + let origin = config + .url + .as_deref() + .and_then(|value| reqwest::Url::parse(value).ok()) + .and_then(|url| { + let host = url.host_str()?; + Some(match url.port() { + Some(port) => format!("{}://{}:{}", url.scheme(), host, port), + None => format!("{}://{}", url.scheme(), host), + }) + }) + .unwrap_or_else(|| "unknown".to_string()); + format!( + "type: {server_type}; transport: {transport}; remote origin: {}; HTTP headers: {}", + origin, + if config.headers.is_empty() { + "none" + } else { + "configured" + } + ) + } +} + +fn external_action( + entry: &ExternalMcpCatalogEntry, + snapshot: &ExternalSourceCatalogSnapshot, +) -> McpManagementAction { + match &entry.activation_state { + ExternalMcpActivationState::ApprovalRequired + | ExternalMcpActivationState::Declined + | ExternalMcpActivationState::ConfigurationChanged => { + McpManagementAction::ExternalDecision { + candidate_id: entry.candidate_id.clone(), + decision_key: entry.decision_key.clone(), + approved: true, + expected_mcp_generation: snapshot.mcp_generation, + expected_preference_revision: snapshot.preference_revision, + } + } + ExternalMcpActivationState::Starting + | ExternalMcpActivationState::Active + | ExternalMcpActivationState::RuntimeUnavailable { .. } => { + McpManagementAction::ExternalDecision { + candidate_id: entry.candidate_id.clone(), + decision_key: entry.decision_key.clone(), + approved: false, + expected_mcp_generation: snapshot.mcp_generation, + expected_preference_revision: snapshot.preference_revision, + } + } + ExternalMcpActivationState::Conflict | ExternalMcpActivationState::Covered { .. } => { + snapshot + .mcp_conflicts + .iter() + .find(|conflict| { + conflict + .candidates + .iter() + .any(|candidate| candidate.candidate_id == entry.candidate_id) + }) + .map(|conflict| McpManagementAction::ConflictChoice { + conflict_key: conflict.conflict_key.clone(), + candidate_id: entry.candidate_id.clone(), + approve_external: true, + expected_mcp_generation: snapshot.mcp_generation, + expected_preference_revision: snapshot.preference_revision, + }) + .unwrap_or_else(|| McpManagementAction::ReadOnly { + reason: "Refresh to review the current conflict".to_string(), + }) + } + ExternalMcpActivationState::Unsupported { reason } => McpManagementAction::ReadOnly { + reason: format!("Not supported: {reason}"), + }, + ExternalMcpActivationState::SourceDisabled => McpManagementAction::ReadOnly { + reason: "Enable this server in the source application".to_string(), + }, + ExternalMcpActivationState::Removed => McpManagementAction::ReadOnly { + reason: "Removed".to_string(), + }, + _ => McpManagementAction::ReadOnly { + reason: "This external MCP state is read-only".to_string(), + }, + } +} + +async fn external_status(entry: &ExternalMcpCatalogEntry, manager: &MCPServerManager) -> String { + match &entry.activation_state { + ExternalMcpActivationState::Active => match entry.runtime_id.as_deref() { + Some(id) => { + match tokio::time::timeout(Duration::from_millis(30), manager.get_server_status(id)) + .await + { + Ok(Ok(value)) => format!("{value:?}"), + Ok(Err(_)) => "Unavailable".to_string(), + Err(_) => "Starting".to_string(), + } + } + None => "Enabled".to_string(), + }, + ExternalMcpActivationState::ApprovalRequired => "Confirmation required".to_string(), + ExternalMcpActivationState::Starting => "Starting".to_string(), + ExternalMcpActivationState::Declined => "Kept disabled".to_string(), + ExternalMcpActivationState::Conflict => "Choice required".to_string(), + ExternalMcpActivationState::Covered { .. } => "Not selected".to_string(), + ExternalMcpActivationState::SourceDisabled => "Source disabled".to_string(), + ExternalMcpActivationState::ConfigurationChanged => "Changed; confirm again".to_string(), + ExternalMcpActivationState::Unsupported { .. } => "Not supported".to_string(), + ExternalMcpActivationState::RuntimeUnavailable { reason } => { + format!("Unavailable - {reason}") + } + ExternalMcpActivationState::Removed => "Removed".to_string(), + _ => "Unavailable".to_string(), + } +} + +fn external_detail(entry: &ExternalMcpCatalogEntry) -> String { + let definition = &entry.definition; + match definition.transport { + ExternalMcpTransportKind::LocalStdio => format!( + "source MCP configuration; local command: {}; arguments: {}; environment variables set: {}", + definition.command_preview.as_deref().unwrap_or("unknown"), + definition.argument_count, + if definition.environment_keys.is_empty() { "none" } else { "configured" }, + ), + ExternalMcpTransportKind::StreamableHttp => format!( + "source MCP configuration; remote origin: {}; HTTP headers: {}", + definition.remote_url_preview.as_deref().unwrap_or("unknown"), + if definition.header_names.is_empty() { "none" } else { "configured" }, + ), + _ => "unsupported external MCP transport".to_string(), + } +} + +fn config_from_mutation( + name: &str, + mutation: McpManagementMutation, +) -> Result { + let (server_type, transport) = match mutation.transport { + McpManagementTransport::Stdio => (MCPServerType::Local, MCPServerTransport::Stdio), + McpManagementTransport::Sse => (MCPServerType::Remote, MCPServerTransport::Sse), + McpManagementTransport::StreamableHttp => { + (MCPServerType::Remote, MCPServerTransport::StreamableHttp) + } + }; + let oauth = mutation + .oauth + .map(serde_json::from_value) + .transpose() + .map_err(|_| McpManagementError::invalid("MCP OAuth configuration is invalid"))?; + let xaa = mutation + .xaa + .map(serde_json::from_value) + .transpose() + .map_err(|_| McpManagementError::invalid("MCP XAA configuration is invalid"))?; + Ok(MCPServerConfig { + id: name.to_string(), + name: name.to_string(), + server_type, + transport: Some(transport), + command: mutation.command, + args: mutation.args, + env: mutation.env, + working_directory: None, + inherit_parent_environment: None, + headers: mutation.headers, + url: mutation.url, + auto_start: mutation.auto_start, + enabled: mutation.enabled, + location: ConfigLocation::User, + capabilities: Vec::new(), + settings: HashMap::new(), + oauth, + oauth_enabled: None, + xaa, + timeouts: Default::default(), + }) +} + +fn schedule_stop(manager: Arc, server_id: String) { + tokio::spawn(async move { + for attempt in 1..=20 { + let result = + tokio::time::timeout(Duration::from_millis(250), manager.stop_server(&server_id)) + .await; + match result { + Ok(Ok(())) | Ok(Err(crate::util::errors::BitFunError::NotFound(_))) => return, + Ok(Err(error)) => log::debug!( + "Best-effort MCP stop failed: id={} attempt={} error={}", + server_id, + attempt, + error + ), + Err(_) => log::debug!( + "Best-effort MCP stop timed out: id={} attempt={}", + server_id, + attempt + ), + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + log::warn!("Best-effort MCP stop exhausted retries: id={}", server_id); + }); +} + +fn sanitize_error(message: String) -> String { + let mut result = message + .chars() + .filter(|character| !character.is_control()) + .take(500) + .collect::(); + if result.is_empty() { + result.push_str("MCP management operation failed"); + } + result +} diff --git a/src/crates/assembly/core/src/service/mcp/management_tests.rs b/src/crates/assembly/core/src/service/mcp/management_tests.rs new file mode 100644 index 0000000000..2acb768fa4 --- /dev/null +++ b/src/crates/assembly/core/src/service/mcp/management_tests.rs @@ -0,0 +1,35 @@ +use super::{McpManagementMutation, McpManagementTransport}; + +#[test] +fn management_mutation_debug_redacts_connection_secrets() { + let mutation = McpManagementMutation { + transport: McpManagementTransport::Stdio, + command: Some("secret-command".to_string()), + args: vec!["--token=secret-argument".to_string()], + env: std::collections::HashMap::from([("API_TOKEN".to_string(), "secret-env".to_string())]), + headers: std::collections::HashMap::from([( + "Authorization".to_string(), + "Bearer secret-header".to_string(), + )]), + url: Some("https://example.com?token=secret-url".to_string()), + auto_start: true, + enabled: true, + oauth: Some(serde_json::json!({"clientSecret": "secret-oauth"})), + xaa: Some(serde_json::json!({"token": "secret-xaa"})), + }; + + let debug = format!("{mutation:?}"); + for secret in [ + "secret-command", + "secret-argument", + "secret-env", + "secret-header", + "secret-url", + "secret-oauth", + "secret-xaa", + ] { + assert!(!debug.contains(secret), "debug leaked {secret}"); + } + assert!(debug.contains("API_TOKEN")); + assert!(debug.contains("Authorization")); +} diff --git a/src/crates/assembly/core/src/service/mcp/mod.rs b/src/crates/assembly/core/src/service/mcp/mod.rs index c6d42dec87..f25a864351 100644 --- a/src/crates/assembly/core/src/service/mcp/mod.rs +++ b/src/crates/assembly/core/src/service/mcp/mod.rs @@ -12,11 +12,16 @@ pub mod adapter; pub mod auth; pub mod config; +#[cfg(feature = "external-sources")] +mod management; pub mod protocol; pub mod server; mod tool_info; mod tool_name; +#[cfg(all(test, feature = "external-sources"))] +mod management_tests; + use std::sync::Arc; use std::sync::OnceLock; @@ -36,6 +41,11 @@ pub use adapter::{ }; pub use config::{ConfigLocation, MCPConfigService}; +#[cfg(feature = "external-sources")] +pub use management::{ + McpManagementAction, McpManagementError, McpManagementErrorKind, McpManagementList, + McpManagementMutation, McpManagementServer, McpManagementService, McpManagementTransport, +}; pub use tool_info::McpToolInfo; pub use tool_name::{ build_mcp_tool_name, normalize_name_for_mcp, MCP_TOOL_DELIMITER, MCP_TOOL_PREFIX, diff --git a/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs b/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs index 4c582263cb..4298c8784b 100644 --- a/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs +++ b/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs @@ -104,6 +104,99 @@ pub struct AccountLoginResult { pub routing_error: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountManagementErrorKind { + InvalidRequest, + Internal, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountManagementError { + pub kind: AccountManagementErrorKind, + pub message: String, +} + +impl AccountManagementError { + fn invalid_request(message: impl Into) -> Self { + Self { + kind: AccountManagementErrorKind::InvalidRequest, + message: message.into(), + } + } + + fn internal(error: impl std::fmt::Display) -> Self { + Self { + kind: AccountManagementErrorKind::Internal, + message: bounded_management_text(error.to_string()), + } + } +} + +impl std::fmt::Display for AccountManagementError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for AccountManagementError {} + +pub fn validate_management_operation_id( + operation_id: &str, +) -> std::result::Result<(), AccountManagementError> { + let valid = !operation_id.trim().is_empty() + && operation_id.len() <= 128 + && operation_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')); + valid + .then_some(()) + .ok_or_else(|| AccountManagementError::invalid_request("Account operation ID is invalid")) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountLoginNextStep { + ChooseSettingsSource, + Ready, + RoutingUnavailable(String), +} + +impl AccountLoginResult { + pub fn next_step(&self) -> AccountLoginNextStep { + if self.has_cloud_settings { + AccountLoginNextStep::ChooseSettingsSource + } else if self.routing_connected { + AccountLoginNextStep::Ready + } else if let Some(error) = &self.routing_error { + AccountLoginNextStep::RoutingUnavailable(bounded_management_text(error.clone())) + } else { + AccountLoginNextStep::Ready + } + } +} + +fn bounded_management_text(message: String) -> String { + message + .chars() + .filter(|character| !character.is_control()) + .take(500) + .collect() +} + +fn redact_management_login_error( + error: anyhow::Error, + relay_url: &str, + username: &str, + password: &str, +) -> AccountManagementError { + let mut message = error.to_string(); + for secret in [relay_url, username, password] { + if !secret.is_empty() { + message = message.replace(secret, ""); + } + } + AccountManagementError::internal(message) +} + #[derive(Debug, Clone)] pub struct AccountInfo { pub user_id: String, @@ -514,6 +607,19 @@ impl AccountRuntime { }) } + pub async fn login_for_management( + self: &Arc, + operation_id: &str, + relay_url: &str, + username: &str, + password: &str, + ) -> std::result::Result { + validate_management_operation_id(operation_id)?; + self.login_with_credentials(relay_url, username, password) + .await + .map_err(|error| redact_management_login_error(error, relay_url, username, password)) + } + pub async fn finalize_login_after_sync_choice(self: &Arc) -> Result<()> { let generation = self.account_context_generation(); let sync_guard = self.lock_account_sync(generation).await?; @@ -657,6 +763,80 @@ impl AccountRuntime { } } + pub async fn finalize_login_for_management( + self: &Arc, + operation_id: String, + use_local_settings: bool, + workspace_path: PathBuf, + ) -> std::result::Result { + validate_management_operation_id(&operation_id)?; + self.finalize_login_after_sync_choice() + .await + .map_err(AccountManagementError::internal)?; + if !self + .start_auto_sync_background(operation_id, use_local_settings, workspace_path) + .await + { + return Err(AccountManagementError::invalid_request( + "Account settings sync is already in progress", + )); + } + Ok(self.snapshot().await) + } + + pub async fn logout_for_management( + &self, + operation_id: String, + ) -> std::result::Result { + validate_management_operation_id(&operation_id)?; + self.cancel_sync(operation_id) + .await + .map_err(AccountManagementError::internal)?; + Ok(self.snapshot().await) + } + + pub async fn start_settings_sync_for_management( + self: &Arc, + operation_id: String, + is_first_login: bool, + workspace_path: PathBuf, + ) -> std::result::Result { + validate_management_operation_id(&operation_id)?; + if !self.is_logged_in().await { + return Err(AccountManagementError::invalid_request( + "Account login must be finalized before settings sync starts", + )); + } + if !self + .start_auto_sync_background(operation_id, is_first_login, workspace_path) + .await + { + return Err(AccountManagementError::invalid_request( + "Account settings sync is already in progress", + )); + } + Ok(self.current_sync_progress().await) + } + + pub async fn cancel_settings_sync_for_management( + &self, + operation_id: String, + ) -> std::result::Result { + validate_management_operation_id(&operation_id)?; + self.cancel_sync(operation_id) + .await + .map_err(AccountManagementError::internal) + } + + pub async fn notify_local_settings_changed_for_management( + &self, + operation_id: &str, + ) -> std::result::Result { + validate_management_operation_id(operation_id)?; + self.notify_local_settings_changed(); + Ok(self.current_sync_progress().await) + } + pub fn start_settings_sync_loop(self: &Arc) { let weak_runtime = Arc::downgrade(self); let context_runtime = weak_runtime.clone(); @@ -1262,6 +1442,47 @@ mod tests { assert!(ensure_session_backup_complete(4, 1, &["quota full".to_string()]).is_err()); } + #[test] + fn management_operation_ids_are_validated_once_by_the_account_owner() { + assert!(validate_management_operation_id("account-op.1").is_ok()); + let error = + validate_management_operation_id("bad operation").expect_err("spaces must be rejected"); + assert_eq!(error.kind, AccountManagementErrorKind::InvalidRequest); + assert_eq!(error.message, "Account operation ID is invalid"); + } + + #[test] + fn login_outcome_exposes_typed_next_step_without_surface_copy() { + let result = AccountLoginResult { + user_id: "user-1".to_string(), + relay_url: "https://relay.example".to_string(), + has_cloud_settings: true, + routing_owner_replaced: false, + routing_connected: false, + routing_error: None, + }; + + assert_eq!( + result.next_step(), + AccountLoginNextStep::ChooseSettingsSource + ); + } + + #[test] + fn management_login_errors_redact_credentials_before_crossing_adapters() { + let error = redact_management_login_error( + anyhow!("relay=https://relay.example username=user@example.test password=secret-value"), + "https://relay.example", + "user@example.test", + "secret-value", + ); + + assert_eq!(error.kind, AccountManagementErrorKind::Internal); + assert!(!error.message.contains("user@example.test")); + assert!(!error.message.contains("secret-value")); + assert!(error.message.contains("")); + } + #[tokio::test] async fn invalid_login_does_not_advance_the_account_generation() { let runtime = test_runtime(); diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs index 8d49d07081..ecb9c659cf 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs @@ -14,6 +14,7 @@ //! `complete_im_bot_pairing`, `current_bot_language`, //! `execute_forwarded_turn`, `apply_interactive_request`. +use bitfun_services_integrations::remote_connect::RemoteUserInputIdentity; use log::{error, info}; use serde_json::Value; use std::sync::{Arc, OnceLock}; @@ -2237,7 +2238,7 @@ async fn route_pending( } } PendingAction::AskUserQuestion { - tool_id, + identity, questions, current_index, answers, @@ -2246,7 +2247,7 @@ async fn route_pending( } => { handle_question_reply( state, - tool_id, + identity, questions, current_index, answers, @@ -2484,7 +2485,7 @@ fn parse_question_numbers(input: &str) -> Option> { #[allow(clippy::too_many_arguments)] async fn handle_question_reply( state: &mut BotChatState, - tool_id: String, + identity: RemoteUserInputIdentity, questions: Vec, current_index: usize, mut answers: Vec, @@ -2501,7 +2502,7 @@ async fn handle_question_reply( let custom_text = message.trim(); if custom_text.is_empty() { state.set_pending(PendingAction::AskUserQuestion { - tool_id, + identity, questions, current_index, answers, @@ -2527,7 +2528,7 @@ async fn handle_question_reply( Some(values) => values, None => { state.set_pending(PendingAction::AskUserQuestion { - tool_id, + identity, questions, current_index, answers, @@ -2539,7 +2540,7 @@ async fn handle_question_reply( }; if !question.multi_select && selections.len() != 1 { state.set_pending(PendingAction::AskUserQuestion { - tool_id, + identity, questions, current_index, answers, @@ -2559,7 +2560,7 @@ async fn handle_question_reply( labels.push(Value::String(question.options[selection - 1].label.clone())); } else { state.set_pending(PendingAction::AskUserQuestion { - tool_id, + identity, questions, current_index, answers, @@ -2577,7 +2578,7 @@ async fn handle_question_reply( }; if includes_other { state.set_pending(PendingAction::AskUserQuestion { - tool_id, + identity, questions, current_index, answers, @@ -2596,7 +2597,7 @@ async fn handle_question_reply( if current_index + 1 < questions.len() { let view = build_question_view(s, &questions, current_index + 1, false); state.set_pending(PendingAction::AskUserQuestion { - tool_id, + identity, questions, current_index: current_index + 1, answers, @@ -2607,11 +2608,11 @@ async fn handle_question_reply( } state.clear_pending(); - submit_question_answers(&tool_id, &answers, s).await + submit_question_answers(&identity, &answers, s).await } async fn submit_question_answers( - tool_id: &str, + identity: &RemoteUserInputIdentity, answers: &[Value], s: &'static BotStrings, ) -> HandleResult { @@ -2622,7 +2623,16 @@ async fn submit_question_answers( payload.insert(idx.to_string(), value.clone()); } let manager = get_user_input_manager(); - match manager.send_answer(tool_id, Value::Object(payload)) { + let result = manager + .send_answer(bitfun_runtime_ports::AgentUserAnswersRequest { + session_id: identity.session_id.clone(), + turn_id: identity.turn_id.clone(), + tool_id: identity.tool_id.clone(), + registration_sequence: identity.registration_sequence, + answers: Value::Object(payload), + }) + .map_err(|error| error.to_string()); + match result { Ok(_) => HandleResult { reply: s.answers_submitted.to_string(), actions: vec![], @@ -2840,44 +2850,33 @@ pub async fn execute_forwarded_turn( } response.push_str(&t); } - TrackerEvent::ToolStarted { - tool_id, - tool_name, - params, - } => { + TrackerEvent::ToolStarted { .. } => {} + TrackerEvent::UserInputRequested { identity, params } => { if !streams_our_turn() { continue; } - // Only AskUserQuestion needs an IM-side prompt; every - // other tool call is internal and not surfaced to the - // user (verbose mode keeps thinking summaries only — - // see ToolCompleted handler below). - if tool_name == "AskUserQuestion" { - if let Some(questions_value) = - params.and_then(|p| p.get("questions").cloned()) + if let Some(questions_value) = params.get("questions").cloned() { + if let Ok(questions) = + serde_json::from_value::>(questions_value) { - if let Ok(questions) = - serde_json::from_value::>(questions_value) - { - let view = build_question_view(s, &questions, 0, false); - let actions: Vec = - view.items.iter().cloned().map(BotAction::from).collect(); - let request = BotInteractiveRequest { - reply: view.render_text_block(), - actions, - menu: view, - pending_action: PendingAction::AskUserQuestion { - tool_id, - questions, - current_index: 0, - answers: Vec::new(), - awaiting_custom_text: false, - pending_answer: None, - }, - }; - if let Some(handler) = interaction_handler.as_ref() { - handler(request).await; - } + let view = build_question_view(s, &questions, 0, false); + let actions: Vec = + view.items.iter().cloned().map(BotAction::from).collect(); + let request = BotInteractiveRequest { + reply: view.render_text_block(), + actions, + menu: view, + pending_action: PendingAction::AskUserQuestion { + identity, + questions, + current_index: 0, + answers: Vec::new(), + awaiting_custom_text: false, + pending_answer: None, + }, + }; + if let Some(handler) = interaction_handler.as_ref() { + handler(request).await; } } } @@ -3391,3 +3390,63 @@ mod handle_chat_tests { ); } } + +#[cfg(test)] +mod question_identity_tests { + use super::*; + + #[tokio::test] + async fn stale_bot_question_identity_cannot_answer_a_reused_tool_id() { + let tool_id = format!("bot-question-{}", uuid::Uuid::new_v4()); + let manager = crate::agentic::tools::user_input_manager::get_user_input_manager(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + let registration_sequence = manager.register( + bitfun_runtime_ports::PendingUserInput { + tool_id: tool_id.clone(), + session_id: "session-b".to_string(), + turn_id: "turn-b".to_string(), + source_session_id: "session-b".to_string(), + source_turn_id: "turn-b".to_string(), + registration_sequence: 0, + input: serde_json::json!({ "questions": [] }), + }, + sender, + ); + let stale = RemoteUserInputIdentity { + session_id: "session-a".to_string(), + turn_id: "turn-a".to_string(), + tool_id: tool_id.clone(), + registration_sequence: registration_sequence.saturating_sub(1), + }; + let current = RemoteUserInputIdentity { + session_id: "session-b".to_string(), + turn_id: "turn-b".to_string(), + tool_id: tool_id.clone(), + registration_sequence, + }; + let strings = strings_for(BotLanguage::EnUS); + + let stale_result = submit_question_answers( + &stale, + &[serde_json::Value::String("stale".to_string())], + strings, + ) + .await; + assert!(stale_result + .reply + .contains(strings.answers_submit_failed_prefix)); + assert!(manager.has_pending(&tool_id)); + + let accepted = submit_question_answers( + ¤t, + &[serde_json::Value::String("current".to_string())], + strings, + ) + .await; + assert_eq!(accepted.reply, strings.answers_submitted); + assert_eq!( + receiver.await.expect("answer delivered").answers, + serde_json::json!({ "0": "current" }) + ); + } +} diff --git a/src/crates/assembly/core/src/service/remote_connect/remote_server.rs b/src/crates/assembly/core/src/service/remote_connect/remote_server.rs index 92e9a8438f..d6f72d5169 100644 --- a/src/crates/assembly/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/assembly/core/src/service/remote_connect/remote_server.rs @@ -421,14 +421,28 @@ mod tests { #[tokio::test] async fn remote_answer_question_preserves_user_input_manager_path() { let (sender, receiver) = tokio::sync::oneshot::channel(); - crate::agentic::tools::user_input_manager::get_user_input_manager() - .register_channel("question-tool".to_string(), sender); + let registration_sequence = + crate::agentic::tools::user_input_manager::get_user_input_manager().register( + bitfun_runtime_ports::PendingUserInput { + tool_id: "question-tool".to_string(), + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + source_session_id: "session-1".to_string(), + source_turn_id: "turn-1".to_string(), + registration_sequence: 0, + input: serde_json::json!({ "questions": [] }), + }, + sender, + ); let bridge = RemoteServer::new([7; 32]); let answers = serde_json::json!({ "choice": "yes" }); let response = bridge .dispatch(&RemoteCommand::AnswerQuestion { + session_id: Some("session-1".to_string()), + turn_id: Some("turn-1".to_string()), tool_id: "question-tool".to_string(), + registration_sequence: Some(registration_sequence), answers: answers.clone(), }) .await; @@ -621,6 +635,7 @@ mod tests { start_ms: Some(42), input_preview: Some("{\"path\":\"README.md\"}".to_string()), tool_input: None, + user_input_identity: None, }], round_index: 2, items: Some(vec![ChatMessageItem { diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 26c01e2109..2cbb2a623b 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -6,8 +6,8 @@ //! implementations until a reviewed port/provider migration proves equivalence. use bitfun_agent_runtime::sdk::{ - AgentEventSource, AgentInteractionResponsePort, AgentRuntime, AgentRuntimeBuilder, - AgentSessionCompactionPort, AgentSessionForkPort, AgentSessionLineagePort, + AgentContextReloadPort, AgentEventSource, AgentInteractionResponsePort, AgentRuntime, + AgentRuntimeBuilder, AgentSessionCompactionPort, AgentSessionForkPort, AgentSessionLineagePort, AgentSessionModePort, AgentSessionModelPort, AgentSessionRestorePort, AgentSessionRevertPort, AgentSessionUsagePort, AgentTurnSettlementPort, RuntimeError, }; @@ -39,17 +39,18 @@ use bitfun_services_integrations::remote_connect::{ normalize_remote_model_selection as normalize_remote_model_selection_contract, normalize_remote_session_model_id, project_remote_chat_user, remote_dialog_submit_outcome_from_scheduler, remote_model_selection_needs_config, ChatMessage, - RemoteAssistantWorkspaceFacts, RemoteCancelRuntimeHost, RemoteChatHistoryRound, - RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, - RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteConnectSubmissionSource, - RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, - RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSubmissionPolicy, - RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, RemoteImageContext, - RemoteInitialSyncRuntimeHost, RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, - RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelFacts, RemotePermissionMode, - RemotePollRuntimeHost, RemoteRecentWorkspaceFacts, RemoteSessionMetadata, - RemoteSessionModelSelection, RemoteSessionRuntimeHost, RemoteSessionStateTracker, - RemoteSessionTrackerHost, RemoteTerminalPrewarmRequest, RemoteWorkspaceFacts, + RemoteActiveTurnSeed, RemoteAssistantWorkspaceFacts, RemoteCancelRuntimeHost, + RemoteChatHistoryRound, RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, + RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, RemoteChatHistoryTurn, + RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, + RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, + RemoteDialogSubmissionPolicy, RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, + RemoteImageContext, RemoteInitialSyncRuntimeHost, RemoteInteractionRuntimeHost, + RemoteModelCapabilityFact, RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelFacts, + RemotePendingUserInputSeed, RemotePermissionMode, RemotePollRuntimeHost, + RemoteRecentWorkspaceFacts, RemoteSessionMetadata, RemoteSessionModelSelection, + RemoteSessionRuntimeHost, RemoteSessionStateTracker, RemoteSessionTrackerHost, + RemoteTerminalPrewarmRequest, RemoteUserInputIdentity, RemoteWorkspaceFacts, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind as RemoteConnectWorkspaceKind, RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, }; @@ -1510,6 +1511,12 @@ impl CoreServiceAgentRuntime { session_usage: Arc, session_lineage: Arc, ) -> Result { + let context_reload: Arc = Arc::new( + crate::product_runtime::CoreAgentRuntimeCompatibility::build( + coordinator.clone(), + scheduler.clone(), + ), + ); let submission: Arc = coordinator.clone(); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); @@ -1538,6 +1545,7 @@ impl CoreServiceAgentRuntime { .with_session_fork_port(session_fork) .with_session_usage_port(session_usage) .with_session_lineage_port(session_lineage) + .with_context_reload_port(context_reload) .with_permission_request_manager( crate::product_runtime::core_permission_request_manager()?, ) @@ -1675,6 +1683,12 @@ impl CoreServiceAgentRuntime { services: bitfun_runtime_services::RuntimeServices, harness_registry: bitfun_harness::HarnessRegistry, ) -> Result { + let context_reload: Arc = Arc::new( + crate::product_runtime::CoreAgentRuntimeCompatibility::build( + coordinator.clone(), + scheduler.clone(), + ), + ); let submission: Arc = coordinator.clone(); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); @@ -1709,6 +1723,7 @@ impl CoreServiceAgentRuntime { cancellation, interaction_response, )? + .with_context_reload_port(context_reload) .with_session_close_port(session_close) .with_session_revert_port(session_revert) .with_dialog_turn_port(dialog_turn) @@ -1789,7 +1804,7 @@ impl RemoteSessionTrackerHost for CoreRemoteSessionTrackerHost { } } - fn active_turn_id(&self, session_id: &str) -> Option { + fn active_turn_seed(&self, session_id: &str) -> Option { let coordinator = get_global_coordinator()?; let session_mgr = coordinator.get_session_manager(); let session = session_mgr.get_session(session_id)?; @@ -1801,7 +1816,24 @@ impl RemoteSessionTrackerHost for CoreRemoteSessionTrackerHost { "Seeded tracker with existing active turn {} for session {}", current_turn_id, session_id ); - Some(current_turn_id.clone()) + let pending_user_inputs = coordinator + .pending_user_inputs_for_session(session_id) + .into_iter() + .filter(|pending| pending.turn_id == *current_turn_id) + .map(|pending| RemotePendingUserInputSeed { + identity: RemoteUserInputIdentity { + session_id: pending.session_id, + turn_id: pending.turn_id, + tool_id: pending.tool_id, + registration_sequence: pending.registration_sequence, + }, + params: pending.input, + }) + .collect(); + Some(RemoteActiveTurnSeed { + turn_id: current_turn_id.clone(), + pending_user_inputs, + }) } _ => None, } @@ -2449,9 +2481,20 @@ impl RemoteInteractionRuntimeHost for CoreRemoteInteractionRuntimeHost { .map_err(|error| error.to_string()) } - fn answer_question(&self, tool_id: &str, answers: serde_json::Value) -> Result<(), String> { - crate::agentic::tools::user_input_manager::get_user_input_manager() - .send_answer(tool_id, answers) + fn answer_question( + &self, + identity: &bitfun_services_integrations::remote_connect::RemoteUserInputIdentity, + answers: serde_json::Value, + ) -> Result<(), String> { + let manager = crate::agentic::tools::user_input_manager::get_user_input_manager(); + manager + .send_answer(bitfun_runtime_ports::AgentUserAnswersRequest { + session_id: identity.session_id.clone(), + turn_id: identity.turn_id.clone(), + tool_id: identity.tool_id.clone(), + registration_sequence: identity.registration_sequence, + answers, + }) .map_err(|error| error.to_string()) } } @@ -2737,6 +2780,8 @@ mod tests { assert!(builder .contains("let session_mode: Arc = coordinator.clone();")); assert!(builder.contains(".with_session_mode_port(session_mode)")); + assert!(builder.contains("let context_reload: Arc = Arc::new(")); + assert!(builder.contains(".with_context_reload_port(context_reload)")); } #[test] diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index c022a5c14f..d00bc4ab76 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -485,6 +485,22 @@ pub enum ToolEventData { #[serde(skip_serializing_if = "Option::is_none")] timeout_seconds: Option, }, + /// The tool has registered an authoritative pending user-input channel. + /// This follows `Started` and makes restore/subscription handoff lossless. + UserInputRequested { + #[serde(flatten)] + identity: ToolEventIdentity, + registration_sequence: u64, + params: serde_json::Value, + }, + /// The authoritative pending user-input registration stopped waiting. + /// Consumers must match the complete registration identity before revoking + /// an answerable prompt because provider tool ids may be reused. + UserInputResolved { + #[serde(flatten)] + identity: ToolEventIdentity, + registration_sequence: u64, + }, Progress { #[serde(flatten)] identity: ToolEventIdentity, @@ -712,6 +728,8 @@ impl ToolEventData { | Self::Queued { identity, .. } | Self::Waiting { identity, .. } | Self::Started { identity, .. } + | Self::UserInputRequested { identity, .. } + | Self::UserInputResolved { identity, .. } | Self::Progress { identity, .. } | Self::Streaming { identity, .. } | Self::StreamChunk { identity, .. } @@ -739,9 +757,12 @@ impl ToolEventData { /// Get the default priority for a specific tool event variant. pub fn default_priority(&self) -> AgenticEventPriority { match self { - Self::Cancelled { .. } => AgenticEventPriority::Critical, + Self::Cancelled { .. } | Self::UserInputResolved { .. } => { + AgenticEventPriority::Critical + } Self::Started { .. } + | Self::UserInputRequested { .. } | Self::Completed { .. } | Self::Failed { .. } | Self::ConfirmationNeeded { .. } => AgenticEventPriority::High, @@ -814,6 +835,21 @@ mod tests { } } + #[test] + fn user_input_resolved_deserializes_the_authoritative_registration_identity() { + let event = serde_json::from_value::(json!({ + "event_type": "UserInputResolved", + "tool_id": "question-1", + "tool_name": "AskUserQuestion", + "registration_sequence": 7 + })); + + assert!( + event.is_ok(), + "resolved user input must be a typed tool event" + ); + } + #[test] fn legacy_context_compression_completion_defaults_to_applied() { let event: AgenticEvent = serde_json::from_value(json!({ diff --git a/src/crates/contracts/runtime-ports/src/agent_api.rs b/src/crates/contracts/runtime-ports/src/agent_api.rs index ac45f42dd8..75010d3967 100644 --- a/src/crates/contracts/runtime-ports/src/agent_api.rs +++ b/src/crates/contracts/runtime-ports/src/agent_api.rs @@ -282,7 +282,10 @@ pub trait AgentContextReloadPort: Send + Sync { #[serde(rename_all = "camelCase")] /// Delivers answers to a pending user-question tool call. pub struct AgentUserAnswersRequest { + pub session_id: String, + pub turn_id: String, pub tool_id: String, + pub registration_sequence: u64, pub answers: serde_json::Value, } @@ -1821,6 +1824,21 @@ pub struct SessionTranscript { pub messages: Vec, } +/// Authoritative interactive-input fact retained while an Agent Turn waits for +/// the user. The controlling Session/Turn can differ from the source when a +/// subagent question is routed to its root TUI. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingUserInput { + pub tool_id: String, + pub session_id: String, + pub turn_id: String, + pub source_session_id: String, + pub source_turn_id: String, + pub registration_sequence: u64, + pub input: serde_json::Value, +} + /// Read-only transcript content shared by runtime consumers. /// /// This projection preserves portable history facts without exposing the Core persistence diff --git a/src/crates/execution/agent-runtime/examples/sdk_minimal.rs b/src/crates/execution/agent-runtime/examples/sdk_minimal.rs index 00a8d01ced..263f075f41 100644 --- a/src/crates/execution/agent-runtime/examples/sdk_minimal.rs +++ b/src/crates/execution/agent-runtime/examples/sdk_minimal.rs @@ -49,7 +49,7 @@ impl AgentSubmissionPort for ExampleAgentProvider { #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box> { let compatibility = AgentRuntimeSdkCompatibility::current(); - assert_eq!(compatibility.api_version, 6); + assert_eq!(compatibility.api_version, 7); let provider = Arc::new(ExampleAgentProvider::default()); let events = AgentEventStream::new(); diff --git a/src/crates/execution/agent-runtime/src/event_queue.rs b/src/crates/execution/agent-runtime/src/event_queue.rs index 75257564af..877666d0eb 100644 --- a/src/crates/execution/agent-runtime/src/event_queue.rs +++ b/src/crates/execution/agent-runtime/src/event_queue.rs @@ -8,10 +8,10 @@ use bitfun_events::{ use log::{debug, trace, warn}; use std::collections::{BinaryHeap, HashMap}; use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, RwLock as StdRwLock, Weak, + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex as StdMutex, RwLock as StdRwLock, Weak, }; -use tokio::sync::{broadcast, Mutex, Notify}; +use tokio::sync::{broadcast, Notify}; const MIN_EVENT_BROADCAST_BUFFER: usize = 1024; // Session-scoped protocol consumers can pause while servicing an RPC. Keep a @@ -26,6 +26,34 @@ struct SessionBroadcast { type SessionBroadcastMap = HashMap>; +struct QueuedEvent { + sequence: u64, + envelope: EventEnvelope, +} + +impl PartialEq for QueuedEvent { + fn eq(&self, other: &Self) -> bool { + self.sequence == other.sequence + } +} + +impl Eq for QueuedEvent {} + +impl PartialOrd for QueuedEvent { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for QueuedEvent { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match self.envelope.priority.cmp(&other.envelope.priority) { + std::cmp::Ordering::Equal => self.sequence.cmp(&other.sequence), + ordering => ordering, + } + } +} + /// Receiver for one session's bounded event stream. /// /// Dropping the last receiver removes the channel immediately so inactive @@ -115,7 +143,10 @@ pub struct QueueStats { /// - Event driven (Notify mechanism) pub struct EventQueue { /// Priority queue - queue: Arc>>>, + queue: Arc>>>, + + /// Stable producer order for events with the same priority. + next_sequence: AtomicU64, /// Notifier (used to wake up waiting consumers) notify: Arc, @@ -135,7 +166,7 @@ pub struct EventQueue { config: EventQueueConfig, /// Statistics - stats: Arc>, + stats: Arc>, } impl EventQueue { @@ -146,13 +177,14 @@ impl EventQueue { let broadcast_capacity = config.max_queue_size.max(MIN_EVENT_BROADCAST_BUFFER); let (broadcast_tx, _) = broadcast::channel(broadcast_capacity); Self { - queue: Arc::new(Mutex::new(BinaryHeap::new())), + queue: Arc::new(StdMutex::new(BinaryHeap::new())), + next_sequence: AtomicU64::new(0), notify: Arc::new(Notify::new()), broadcast_tx, session_broadcasts: Arc::new(StdRwLock::new(HashMap::new())), has_session_broadcasts: Arc::new(AtomicBool::new(false)), config, - stats: Arc::new(Mutex::new(QueueStats::default())), + stats: Arc::new(StdMutex::new(QueueStats::default())), } } @@ -162,20 +194,39 @@ impl EventQueue { event: AgenticEvent, priority: Option, ) -> EventBusResult { + Ok(self.enqueue_now(event, priority)) + } + + /// Enqueue and publish an event without an async suspension point. + /// + /// Queue and statistics critical sections contain no async work, so the + /// synchronous path preserves the same ordering and capacity behavior as + /// [`Self::enqueue`]. Drop-time owner settlement uses this path because + /// its producer future cannot await after cancellation. + pub fn enqueue_now(&self, event: AgenticEvent, priority: Option) -> String { let priority = priority.unwrap_or_else(|| event.default_priority()); let envelope = EventEnvelope::new(event, priority); let event_id = envelope.id.clone(); let (queue_len, queued) = { - let mut queue = self.queue.lock().await; + let mut queue = self.queue.lock().expect("event queue lock poisoned"); if queue.len() >= self.config.max_queue_size { warn!( "Event queue full, skipping legacy queue storage: event_id={}", event_id ); + self.broadcast_envelope(&envelope); (queue.len(), false) } else { - queue.push(std::cmp::Reverse(envelope.clone())); + let sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed); + queue.push(std::cmp::Reverse(QueuedEvent { + sequence, + envelope: envelope.clone(), + })); + // Publish while the queue lock still owns the enqueue order so + // concurrent producers have one sequence across the legacy + // dequeue path and every broadcast subscriber. + self.broadcast_envelope(&envelope); (queue.len(), true) } }; @@ -184,24 +235,8 @@ impl EventQueue { // subscribers and must not depend on capacity in the legacy dequeue // buffer. Session-scoped subscribers receive only matching traffic so // an unrelated session cannot consume their bounded backlog. - let session_channel = if self.has_session_broadcasts.load(Ordering::Acquire) { - envelope.event.session_id().and_then(|session_id| { - self.session_broadcasts - .read() - .expect("session event channels lock poisoned") - .get(session_id) - .cloned() - }) - } else { - None - }; - if let Some(channel) = session_channel { - let _ = channel.sender.send(envelope.clone()); - } - let _ = self.broadcast_tx.send(envelope); - { - let mut stats = self.stats.lock().await; + let mut stats = self.stats.lock().expect("event queue stats lock poisoned"); stats.total_enqueued += 1; stats.pending_events = queue_len; } @@ -216,19 +251,37 @@ impl EventQueue { priority ); - Ok(event_id) + event_id + } + + fn broadcast_envelope(&self, envelope: &EventEnvelope) { + let session_channel = if self.has_session_broadcasts.load(Ordering::Acquire) { + envelope.event.session_id().and_then(|session_id| { + self.session_broadcasts + .read() + .expect("session event channels lock poisoned") + .get(session_id) + .cloned() + }) + } else { + None + }; + if let Some(channel) = session_channel { + let _ = channel.sender.send(envelope.clone()); + } + let _ = self.broadcast_tx.send(envelope.clone()); } /// Dequeue batch of events pub async fn dequeue_batch(&self, max_size: usize) -> Vec { let mut batch = Vec::new(); - let mut queue = self.queue.lock().await; + let mut queue = self.queue.lock().expect("event queue lock poisoned"); let take_count = max_size.min(queue.len()); for _ in 0..take_count { - if let Some(std::cmp::Reverse(envelope)) = queue.pop() { - batch.push(envelope); + if let Some(std::cmp::Reverse(queued)) = queue.pop() { + batch.push(queued.envelope); } } let remaining_queue_len = queue.len(); @@ -259,7 +312,7 @@ impl EventQueue { // Update statistics if !batch.is_empty() { - let mut stats = self.stats.lock().await; + let mut stats = self.stats.lock().expect("event queue stats lock poisoned"); stats.total_processed += batch.len() as u64; stats.pending_events = remaining_queue_len; } @@ -336,12 +389,12 @@ impl EventQueue { pub async fn clear_session(&self, session_id: &str) -> EventBusResult<()> { // Remove all events for this session from the queue let queue_len = { - let mut queue = self.queue.lock().await; + let mut queue = self.queue.lock().expect("event queue lock poisoned"); let mut new_queue = BinaryHeap::new(); - while let Some(std::cmp::Reverse(envelope)) = queue.pop() { - if envelope.event.session_id() != Some(session_id) { - new_queue.push(std::cmp::Reverse(envelope)); + while let Some(std::cmp::Reverse(queued)) = queue.pop() { + if queued.envelope.event.session_id() != Some(session_id) { + new_queue.push(std::cmp::Reverse(queued)); } } @@ -351,7 +404,7 @@ impl EventQueue { // Update statistics: use the size obtained earlier { - let mut stats = self.stats.lock().await; + let mut stats = self.stats.lock().expect("event queue stats lock poisoned"); stats.pending_events = queue_len; } @@ -362,7 +415,10 @@ impl EventQueue { /// Get queue statistics pub async fn stats(&self) -> QueueStats { - self.stats.lock().await.clone() + self.stats + .lock() + .expect("event queue stats lock poisoned") + .clone() } /// Wait for events (used for consumers) @@ -372,12 +428,15 @@ impl EventQueue { /// Get queue size pub async fn len(&self) -> usize { - self.queue.lock().await.len() + self.queue.lock().expect("event queue lock poisoned").len() } /// Check if the queue is empty pub async fn is_empty(&self) -> bool { - self.queue.lock().await.is_empty() + self.queue + .lock() + .expect("event queue lock poisoned") + .is_empty() } } @@ -391,7 +450,7 @@ impl StreamEventSink for EventQueue { #[cfg(test)] mod tests { use super::{EventQueue, EventQueueConfig}; - use bitfun_events::AgenticEvent; + use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; use std::sync::{Arc, Barrier}; #[tokio::test] @@ -436,6 +495,124 @@ mod tests { ); } + #[tokio::test] + async fn immediate_broadcast_reaches_global_and_session_subscribers() { + let queue = EventQueue::new(EventQueueConfig::default()); + let mut global = queue.subscribe(); + let mut session = queue.subscribe_session("session"); + + queue.enqueue_now( + AgenticEvent::SessionStateChanged { + session_id: "session".to_string(), + new_state: "cancelled".to_string(), + }, + None, + ); + + assert_eq!( + global + .recv() + .await + .expect("global broadcast") + .event + .session_id(), + Some("session") + ); + assert_eq!( + session + .recv() + .await + .expect("session broadcast") + .event + .session_id(), + Some("session") + ); + assert_eq!(queue.len().await, 1); + } + + #[tokio::test] + async fn pending_user_input_resolution_precedes_tool_and_turn_terminal_events() { + let queue = EventQueue::new(EventQueueConfig { + max_queue_size: 3, + batch_size: 3, + }); + let mut broadcast = queue.subscribe(); + let identity = ToolEventIdentity::direct("question-1", "AskUserQuestion"); + + queue.enqueue_now( + AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputResolved { + identity: identity.clone(), + registration_sequence: 1, + }, + }, + None, + ); + queue.enqueue_now( + AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Cancelled { + identity, + reason: "turn cancelled".to_string(), + duration_ms: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + }, + }, + None, + ); + queue.enqueue_now( + AgenticEvent::DialogTurnCancelled { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + }, + None, + ); + + let mut broadcast_events = Vec::new(); + for _ in 0..3 { + broadcast_events.push(broadcast.recv().await.expect("broadcast event")); + } + assert!(matches!( + broadcast_events[0].event, + AgenticEvent::ToolEvent { + tool_event: ToolEventData::UserInputResolved { .. }, + .. + } + )); + + let batch = queue.dequeue_configured_batch().await; + assert!(matches!( + batch[0].event, + AgenticEvent::ToolEvent { + tool_event: ToolEventData::UserInputResolved { .. }, + .. + } + )); + assert!(matches!( + batch[1].event, + AgenticEvent::ToolEvent { + tool_event: ToolEventData::Cancelled { .. }, + .. + } + )); + assert!(matches!( + batch[2].event, + AgenticEvent::DialogTurnCancelled { .. } + )); + } + #[tokio::test] async fn default_sized_broadcast_preserves_bursts_above_legacy_1024_limit() { let queue = EventQueue::new(EventQueueConfig::default()); @@ -513,6 +690,13 @@ mod tests { second_ids.push(second.recv().await.expect("second broadcast").id); } assert_eq!(first_ids, second_ids); + let queued_ids = queue + .dequeue_batch(EVENT_COUNT) + .await + .into_iter() + .map(|event| event.id) + .collect::>(); + assert_eq!(first_ids, queued_ids); }); } diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index df1cef15f9..5e424d1fed 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -10,19 +10,20 @@ use std::sync::{Arc, Mutex}; use bitfun_agent_tools::{ToolRegistry, ToolRegistryItem}; use bitfun_harness::HarnessRegistry; use bitfun_runtime_ports::{ - AgentBackgroundResultRequest, AgentDialogSteerRequest, AgentDialogTurnPort, - AgentDialogTurnRequest, AgentInputAttachment, AgentInteractionResponsePort, - AgentLifecycleDeliveryPort, AgentLocalCommandTurnPort, AgentLocalCommandTurnRecordRequest, - AgentLocalCommandTurnRecordResult, AgentMessageWorkspaceReferencesRequest, - AgentSessionArchiveRequest, AgentSessionArchiveStateRequest, AgentSessionClosePort, - AgentSessionCompactionPort, AgentSessionCompactionRequest, AgentSessionCompactionResult, - AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionDeleteRequest, - AgentSessionForkAtTurnRequest, AgentSessionForkBeforeTurnRequest, AgentSessionForkPort, - AgentSessionForkRequest, AgentSessionForkResult, AgentSessionLineageCancellationRequest, - AgentSessionLineageInspection, AgentSessionLineagePort, AgentSessionLineageRequest, - AgentSessionLineageSnapshot, AgentSessionLineageTranscriptRequest, AgentSessionListRequest, - AgentSessionManagementPort, AgentSessionModePort, AgentSessionModeUpdateRequest, - AgentSessionModelPort, AgentSessionModelSelectionUpdateRequest, AgentSessionModelUpdateRequest, + AgentBackgroundResultRequest, AgentContextReloadPort, AgentContextReloadRequest, + AgentDialogSteerRequest, AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, + AgentInteractionResponsePort, AgentLifecycleDeliveryPort, AgentLocalCommandTurnPort, + AgentLocalCommandTurnRecordRequest, AgentLocalCommandTurnRecordResult, + AgentMessageWorkspaceReferencesRequest, AgentSessionArchiveRequest, + AgentSessionArchiveStateRequest, AgentSessionClosePort, AgentSessionCompactionPort, + AgentSessionCompactionRequest, AgentSessionCompactionResult, AgentSessionCreateRequest, + AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionForkAtTurnRequest, + AgentSessionForkBeforeTurnRequest, AgentSessionForkPort, AgentSessionForkRequest, + AgentSessionForkResult, AgentSessionLineageCancellationRequest, AgentSessionLineageInspection, + AgentSessionLineagePort, AgentSessionLineageRequest, AgentSessionLineageSnapshot, + AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionManagementPort, + AgentSessionModePort, AgentSessionModeUpdateRequest, AgentSessionModelPort, + AgentSessionModelSelectionUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRenameRequest, AgentSessionRevertPort, AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionRollbackToTurnOutcome, AgentSessionRollbackToTurnRequest, AgentSessionSummary, AgentSessionUsagePort, AgentSessionUsageRequest, @@ -45,6 +46,7 @@ use bitfun_runtime_services::RuntimeServices; use crate::event_source::{AgentEventReceiver, AgentEventSource, AgentSessionEventReceiver}; use crate::permission::{PermissionRequestEventReceiver, PermissionRequestManager}; use crate::post_call_hooks::RuntimeHookRegistry; +use crate::user_questions::PendingUserInput; use bitfun_runtime_ports::{PermissionReply, PermissionReplySource, PermissionRequest}; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] @@ -69,6 +71,8 @@ pub enum RuntimeError { MissingSessionManagementPort, #[error("agent session lineage port is not registered")] MissingSessionLineagePort, + #[error("agent context reload port is not registered")] + MissingContextReloadPort, #[error("agent workspace reference port is not registered")] MissingWorkspaceReferencePort, #[error("agent session restore port is not registered")] @@ -121,6 +125,8 @@ pub struct AgentSessionRestoreRequest { pub struct AgentSessionRestoreResult { pub session: AgentSessionSummary, pub state: crate::session_state::SessionState, + pub transcript: SessionTranscript, + pub pending_user_inputs: Vec, } #[async_trait::async_trait] @@ -184,6 +190,7 @@ pub struct AgentRuntime { submission: Arc, session_management: Option>, session_lineage: Option>, + context_reload: Option>, workspace_references: Option>, session_close: Option>, session_mode: Option>, @@ -213,6 +220,122 @@ pub struct AgentRuntime { plugin_runtime: PluginRuntimeBinding, } +fn ensure_provider_identity( + operation: &str, + field: &str, + expected: &str, + actual: &str, +) -> Result<(), RuntimeError> { + if expected == actual { + return Ok(()); + } + Err(PortError::new( + PortErrorKind::OutcomeUnknown, + format!( + "{operation} provider returned {field} '{actual}' for requested {field} '{expected}'" + ), + ) + .into()) +} + +fn ensure_optional_provider_identity( + operation: &str, + field: &str, + expected: Option<&str>, + actual: Option<&str>, +) -> Result<(), RuntimeError> { + let Some(expected) = expected else { + return Ok(()); + }; + let Some(actual) = actual else { + return Err(PortError::new( + PortErrorKind::OutcomeUnknown, + format!("{operation} provider omitted {field} for requested {field} '{expected}'"), + ) + .into()); + }; + ensure_provider_identity(operation, field, expected, actual) +} + +fn validate_restored_pending_user_inputs( + requested_session_id: &str, + state: &crate::session_state::SessionState, + pending_user_inputs: &[PendingUserInput], +) -> Result<(), RuntimeError> { + let active_turn_id = match state { + crate::session_state::SessionState::Processing { + current_turn_id, .. + } => Some(current_turn_id.as_str()), + crate::session_state::SessionState::Idle + | crate::session_state::SessionState::Error { .. } => None, + }; + + if active_turn_id.is_none() && !pending_user_inputs.is_empty() { + return Err(PortError::new( + PortErrorKind::OutcomeUnknown, + "restore session provider returned pending user input without an active turn", + ) + .into()); + } + + for pending in pending_user_inputs { + ensure_provider_identity( + "restore session pending user input", + "session_id", + requested_session_id, + &pending.session_id, + )?; + if let Some(active_turn_id) = active_turn_id { + ensure_provider_identity( + "restore session pending user input", + "turn_id", + active_turn_id, + &pending.turn_id, + )?; + } + } + Ok(()) +} + +fn validate_revert_identity( + operation: &str, + requested_session_id: &str, + result: &AgentSessionRevertResult, +) -> Result<(), RuntimeError> { + ensure_provider_identity( + operation, + "session_id", + requested_session_id, + &result.session_id, + )?; + ensure_provider_identity( + operation, + "transcript.session_id", + requested_session_id, + &result.transcript.session_id, + ) +} + +fn validate_cancellation_identity( + operation: &str, + requested_session_id: &str, + requested_turn_id: Option<&str>, + result: &AgentTurnCancellationResult, +) -> Result<(), RuntimeError> { + ensure_provider_identity( + operation, + "session_id", + requested_session_id, + &result.session_id, + )?; + ensure_optional_provider_identity( + operation, + "turn_id", + requested_turn_id, + result.turn_id.as_deref(), + ) +} + impl std::fmt::Debug for AgentRuntime { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AgentRuntime") @@ -408,6 +531,7 @@ pub struct AgentRuntimeBuilder { submission: Option>, session_management: Option>, session_lineage: Option>, + context_reload: Option>, workspace_references: Option>, session_close: Option>, session_mode: Option>, @@ -460,6 +584,11 @@ impl AgentRuntimeBuilder { self } + pub fn with_context_reload_port(mut self, port: Arc) -> Self { + self.context_reload = Some(port); + self + } + pub fn with_workspace_reference_port( mut self, port: Arc, @@ -627,6 +756,7 @@ impl AgentRuntimeBuilder { submission, session_management, session_lineage, + context_reload, workspace_references, session_close, session_mode, @@ -664,6 +794,7 @@ impl AgentRuntimeBuilder { submission: submission.ok_or(RuntimeBuildError::MissingSubmissionPort)?, session_management, session_lineage, + context_reload, workspace_references, session_close, session_mode, @@ -792,6 +923,18 @@ pub struct AgentRunHandle { } impl AgentRuntime { + pub async fn reload_context( + &self, + request: AgentContextReloadRequest, + ) -> Result<(), RuntimeError> { + self.context_reload + .as_ref() + .ok_or(RuntimeError::MissingContextReloadPort)? + .reload_session_context(request) + .await + .map_err(RuntimeError::from) + } + pub fn subscribe_events(&self) -> Result { self.event_source .as_ref() @@ -996,7 +1139,7 @@ impl AgentRuntime { .map_err(RuntimeError::from)?; if result.session_id != session_id { return Err(PortError::new( - PortErrorKind::Backend, + PortErrorKind::OutcomeUnknown, format!( "agent submission provider returned session_id '{}' for requested session_id '{}'", result.session_id, session_id @@ -1019,7 +1162,7 @@ impl AgentRuntime { .map_err(RuntimeError::from)?; if result.session_id != session_id { return Err(PortError::new( - PortErrorKind::Backend, + PortErrorKind::OutcomeUnknown, format!( "agent submission provider returned session_id '{}' for requested transient session_id '{}'", result.session_id, session_id @@ -1125,9 +1268,18 @@ impl AgentRuntime { .local_command_turn .as_ref() .ok_or(RuntimeError::MissingLocalCommandTurnPort)?; - port.record_completed_local_command_turn(request) + let requested_turn_id = request.turn_id.clone(); + let result = port + .record_completed_local_command_turn(request) .await - .map_err(RuntimeError::from) + .map_err(RuntimeError::from)?; + ensure_optional_provider_identity( + "agent local command turn", + "turn_id", + requested_turn_id.as_deref(), + Some(result.turn_id.as_str()), + )?; + Ok(result) } pub async fn run_user_shell_command( @@ -1148,7 +1300,7 @@ impl AgentRuntime { .map_err(RuntimeError::from)?; if result.session_id != requested_session_id || result.turn_id != requested_turn_id { return Err(PortError::new( - PortErrorKind::Backend, + PortErrorKind::OutcomeUnknown, format!( "agent user shell provider returned identity '{}/{}' for requested identity '{}/{}'", result.session_id, result.turn_id, requested_session_id, requested_turn_id @@ -1217,9 +1369,25 @@ impl AgentRuntime { "agent session compaction port is not registered", )) })?; - port.start_session_compaction(request) + let requested_session_id = request.session_id.clone(); + let requested_turn_id = request.turn_id.clone(); + let result = port + .start_session_compaction(request) .await - .map_err(RuntimeError::from) + .map_err(RuntimeError::from)?; + ensure_provider_identity( + "agent session compaction", + "session_id", + &requested_session_id, + &result.session_id, + )?; + ensure_provider_identity( + "agent session compaction", + "turn_id", + &requested_turn_id, + &result.turn_id, + )?; + Ok(result) } pub async fn undo_session( @@ -1232,7 +1400,13 @@ impl AgentRuntime { "agent session revert port is not registered", )) })?; - port.undo_session(request).await.map_err(RuntimeError::from) + let requested_session_id = request.session_id.clone(); + let result = port + .undo_session(request) + .await + .map_err(RuntimeError::from)?; + validate_revert_identity("agent session undo", &requested_session_id, &result)?; + Ok(result) } pub async fn redo_session( @@ -1245,7 +1419,13 @@ impl AgentRuntime { "agent session revert port is not registered", )) })?; - port.redo_session(request).await.map_err(RuntimeError::from) + let requested_session_id = request.session_id.clone(); + let result = port + .redo_session(request) + .await + .map_err(RuntimeError::from)?; + validate_revert_identity("agent session redo", &requested_session_id, &result)?; + Ok(result) } pub async fn rollback_session_to_turn( @@ -1340,14 +1520,33 @@ impl AgentRuntime { &self, request: AgentSessionRestoreRequest, ) -> Result { + let requested_session_id = request.session_id.clone(); let session_restore = self .session_restore .as_ref() .ok_or(RuntimeError::MissingSessionRestorePort)?; - session_restore + let result = session_restore .restore_session(request) .await - .map_err(RuntimeError::from) + .map_err(RuntimeError::from)?; + ensure_provider_identity( + "restore session", + "session_id", + &requested_session_id, + &result.session.session_id, + )?; + ensure_provider_identity( + "restore session", + "transcript.session_id", + &requested_session_id, + &result.transcript.session_id, + )?; + validate_restored_pending_user_inputs( + &requested_session_id, + &result.state, + &result.pending_user_inputs, + )?; + Ok(result) } pub async fn read_session_transcript( @@ -1434,10 +1633,19 @@ impl AgentRuntime { &self, request: AgentSubmissionRequest, ) -> Result { - self.submission + let requested_turn_id = request.turn_id.clone(); + let result = self + .submission .submit_message(request) .await - .map_err(RuntimeError::from) + .map_err(RuntimeError::from)?; + ensure_optional_provider_identity( + "agent submission", + "turn_id", + requested_turn_id.as_deref(), + Some(result.turn_id.as_str()), + )?; + Ok(result) } pub async fn submit_dialog_turn( @@ -1445,6 +1653,7 @@ impl AgentRuntime { request: AgentDialogTurnRequest, ) -> Result { let requested_session_id = request.session_id.clone(); + let requested_turn_id = request.turn_id.clone(); let dialog_turn = self .dialog_turn .as_ref() @@ -1453,13 +1662,19 @@ impl AgentRuntime { .submit_dialog_turn(request) .await .map_err(RuntimeError::from)?; - let returned_session_id = match &outcome { - DialogSubmitOutcome::Started { session_id, .. } - | DialogSubmitOutcome::Queued { session_id, .. } => session_id, + let (returned_session_id, returned_turn_id) = match &outcome { + DialogSubmitOutcome::Started { + session_id, + turn_id, + } + | DialogSubmitOutcome::Queued { + session_id, + turn_id, + } => (session_id, turn_id), }; if returned_session_id != &requested_session_id { return Err(PortError::new( - PortErrorKind::Backend, + PortErrorKind::OutcomeUnknown, format!( "agent dialog provider returned session_id '{}' for requested session_id '{}'", returned_session_id, requested_session_id @@ -1467,6 +1682,12 @@ impl AgentRuntime { ) .into()); } + ensure_optional_provider_identity( + "agent dialog", + "turn_id", + requested_turn_id.as_deref(), + Some(returned_turn_id.as_str()), + )?; Ok(outcome) } @@ -1491,7 +1712,7 @@ impl AgentRuntime { } = &outcome; if session_id != &requested_session_id || turn_id != &requested_turn_id { return Err(PortError::new( - PortErrorKind::Backend, + PortErrorKind::OutcomeUnknown, format!( "agent dialog provider returned session_id '{}' and turn_id '{}' for requested session_id '{}' and turn_id '{}'", session_id, turn_id, requested_session_id, requested_turn_id @@ -1586,14 +1807,23 @@ impl AgentRuntime { &self, request: AgentTurnCancellationRequest, ) -> Result { + let requested_session_id = request.session_id.clone(); + let requested_turn_id = request.turn_id.clone(); let cancellation = self .cancellation .as_ref() .ok_or(RuntimeError::MissingCancellationPort)?; - cancellation + let result = cancellation .cancel_turn(request) .await - .map_err(RuntimeError::from) + .map_err(RuntimeError::from)?; + validate_cancellation_identity( + "agent turn cancellation", + &requested_session_id, + requested_turn_id.as_deref(), + &result, + )?; + Ok(result) } /// Cancels one inspected descendant through the authoritative lineage @@ -1603,14 +1833,23 @@ impl AgentRuntime { &self, request: AgentSessionLineageCancellationRequest, ) -> Result { + let requested_session_id = request.session_id.clone(); + let requested_turn_id = request.expected_active_turn_id.clone(); let lineage = self .session_lineage .as_ref() .ok_or(RuntimeError::MissingSessionLineagePort)?; - lineage + let result = lineage .cancel_lineage_session(request) .await - .map_err(RuntimeError::from) + .map_err(RuntimeError::from)?; + validate_cancellation_identity( + "agent lineage cancellation", + &requested_session_id, + requested_turn_id.as_deref(), + &result, + )?; + Ok(result) } pub async fn publish_event(&self, event: RuntimeEventEnvelope) -> Result<(), RuntimeError> { @@ -1688,24 +1927,25 @@ mod tests { use super::*; use crate::session_state::SessionState; use bitfun_runtime_ports::{ - AgentBackgroundResultRequest, AgentDialogTurnRequest, AgentLifecycleDeliveryPort, - AgentSessionCompactionPort, AgentSessionCompactionRequest, AgentSessionCompactionResult, - AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionLifecycleStatus, - AgentSessionLineageCancellationRequest, AgentSessionLineageEntry, AgentSessionLineagePort, - AgentSessionLineageRequest, AgentSessionLineageSnapshot, - AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionManagementPort, - AgentSessionModePort, AgentSessionModeUpdateRequest, AgentSessionRevertPort, - AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionSummary, - AgentSessionWorkspaceRequest, AgentSubmissionResult, AgentThreadGoalDeliveryKind, - AgentThreadGoalDeliveryRequest, AgentThreadGoalManagementPort, AgentTurnCancellationResult, - AgentUserShellCommandPort, AgentUserShellCommandRequest, AgentUserShellCommandResult, - ClockPort, DialogQueuePriority, DialogSubmissionPolicy, DialogSubmitOutcome, - FileSystemPort, PluginDispatchEnvelope, PluginResponseEnvelope, PluginRuntimeAvailability, - PluginRuntimeClient, PluginRuntimeUnavailableReason, PortErrorKind, PortResult, - RuntimeEventSink, RuntimeEventType, RuntimeServiceCapability, RuntimeServicePort, - SessionStorageKind, SessionStoragePathRequest, SessionStoragePathResolution, - SessionStorePort, SessionTranscript, SessionTranscriptReader, SessionTranscriptRequest, - ThreadGoal, ThreadGoalStatus, TranscriptContent, TranscriptMessage, WorkspacePort, + AgentBackgroundResultRequest, AgentContextReloadTarget, AgentDialogTurnRequest, + AgentLifecycleDeliveryPort, AgentSessionCompactionPort, AgentSessionCompactionRequest, + AgentSessionCompactionResult, AgentSessionCreateResult, AgentSessionDeleteRequest, + AgentSessionLifecycleStatus, AgentSessionLineageCancellationRequest, + AgentSessionLineageEntry, AgentSessionLineagePort, AgentSessionLineageRequest, + AgentSessionLineageSnapshot, AgentSessionLineageTranscriptRequest, AgentSessionListRequest, + AgentSessionManagementPort, AgentSessionModePort, AgentSessionModeUpdateRequest, + AgentSessionRevertPort, AgentSessionRevertRequest, AgentSessionRevertResult, + AgentSessionSummary, AgentSessionWorkspaceRequest, AgentSubmissionResult, + AgentThreadGoalDeliveryKind, AgentThreadGoalDeliveryRequest, AgentThreadGoalManagementPort, + AgentTurnCancellationResult, AgentUserShellCommandPort, AgentUserShellCommandRequest, + AgentUserShellCommandResult, ClockPort, DialogQueuePriority, DialogSubmissionPolicy, + DialogSubmitOutcome, FileSystemPort, PluginDispatchEnvelope, PluginResponseEnvelope, + PluginRuntimeAvailability, PluginRuntimeClient, PluginRuntimeUnavailableReason, + PortErrorKind, PortResult, RuntimeEventSink, RuntimeEventType, RuntimeServiceCapability, + RuntimeServicePort, SessionStorageKind, SessionStoragePathRequest, + SessionStoragePathResolution, SessionStorePort, SessionTranscript, SessionTranscriptReader, + SessionTranscriptRequest, ThreadGoal, ThreadGoalStatus, TranscriptContent, + TranscriptMessage, WorkspacePort, }; use bitfun_runtime_services::RuntimeServicesBuilder; @@ -1758,6 +1998,7 @@ mod tests { submitted_messages: Mutex>, cancelled_turns: Mutex>, compaction_requests: Mutex>, + compaction_result_identity: Mutex>, before_turn_fork_requests: Mutex>, listed_sessions: Mutex>, deleted_sessions: Mutex>, @@ -1765,10 +2006,18 @@ mod tests { archived_sessions: Mutex>, archive_state_updates: Mutex>, local_command_turns: Mutex>, + local_command_result_turn_id: Mutex>, user_shell_commands: Mutex>, restored_sessions: Mutex>, + restore_result_identity: Mutex>, + restore_state: Mutex>, + restore_pending_user_inputs: Mutex>, + context_reloads: Mutex>, mode_updates: Mutex>, undo_requests: Mutex>, + revert_result_identity: Mutex>, + submission_result_turn_id: Mutex>, + cancellation_result_identity: Mutex)>>, transcript_requests: Mutex>, lineage_requests: Mutex>, lineage_transcript_requests: Mutex>, @@ -1927,9 +2176,16 @@ mod tests { .lock() .unwrap() .push(request.clone()); + let identity = self.compaction_result_identity.lock().unwrap().clone(); Ok(AgentSessionCompactionResult { - session_id: request.session_id, - turn_id: request.turn_id, + session_id: identity + .as_ref() + .map(|identity| identity.0.clone()) + .unwrap_or_else(|| request.session_id.clone()), + turn_id: identity + .as_ref() + .map(|identity| identity.1.clone()) + .unwrap_or(request.turn_id), }) } } @@ -1967,10 +2223,16 @@ mod tests { request: AgentSessionRevertRequest, ) -> PortResult { self.undo_requests.lock().unwrap().push(request.clone()); + let identity = self.revert_result_identity.lock().unwrap().clone(); Ok(AgentSessionRevertResult { - session_id: request.session_id.clone(), + session_id: identity + .as_ref() + .map(|identity| identity.0.clone()) + .unwrap_or_else(|| request.session_id.clone()), transcript: SessionTranscript { - session_id: request.session_id, + session_id: identity + .map(|identity| identity.1) + .unwrap_or(request.session_id), messages: Vec::new(), }, composer: bitfun_runtime_ports::AgentSessionComposerUpdate::Preserve, @@ -2001,9 +2263,12 @@ mod tests { ) -> PortResult { let mut turns = self.local_command_turns.lock().unwrap(); let storage_turn_index = turns.len(); - let turn_id = request - .turn_id + let turn_id = self + .local_command_result_turn_id + .lock() + .unwrap() .clone() + .or_else(|| request.turn_id.clone()) .unwrap_or_else(|| format!("local-command-{storage_turn_index}")); turns.push(request); Ok(AgentLocalCommandTurnRecordResult { @@ -2036,10 +2301,17 @@ mod tests { &self, request: AgentSessionRestoreRequest, ) -> PortResult { + let requested_session_id = request.session_id.clone(); self.restored_sessions.lock().unwrap().push(request); + let (session_id, transcript_session_id) = self + .restore_result_identity + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| (requested_session_id.clone(), requested_session_id.clone())); Ok(AgentSessionRestoreResult { session: AgentSessionSummary { - session_id: "session_1".to_string(), + session_id, session_name: "Main".to_string(), agent_type: "agentic".to_string(), model_id: Some("provider/model".to_string()), @@ -2050,7 +2322,17 @@ mod tests { created_at_ms: 1000, last_active_at_ms: 2000, }, - state: SessionState::Idle, + state: self + .restore_state + .lock() + .unwrap() + .clone() + .unwrap_or(SessionState::Idle), + transcript: SessionTranscript { + session_id: transcript_session_id, + messages: Vec::new(), + }, + pending_user_inputs: self.restore_pending_user_inputs.lock().unwrap().clone(), }) } } @@ -2218,6 +2500,14 @@ mod tests { )) } + async fn create_transient_session_with_id( + &self, + session_id: String, + request: AgentSessionCreateRequest, + ) -> PortResult { + self.create_session_with_id(session_id, request).await + } + async fn submit_message( &self, request: AgentSubmissionRequest, @@ -2227,8 +2517,12 @@ mod tests { .unwrap() .push(request.clone()); Ok(AgentSubmissionResult { - turn_id: request - .turn_id + turn_id: self + .submission_result_turn_id + .lock() + .unwrap() + .clone() + .or(request.turn_id) .unwrap_or_else(|| "generated_turn".to_string()), accepted: true, }) @@ -2277,14 +2571,31 @@ mod tests { request: AgentTurnCancellationRequest, ) -> PortResult { self.cancelled_turns.lock().unwrap().push(request.clone()); + let identity = self.cancellation_result_identity.lock().unwrap().clone(); Ok(AgentTurnCancellationResult { - session_id: request.session_id, - turn_id: request.turn_id, + session_id: identity + .as_ref() + .map(|identity| identity.0.clone()) + .unwrap_or(request.session_id), + turn_id: identity + .map(|identity| identity.1) + .unwrap_or(request.turn_id), requested: true, }) } } + #[async_trait::async_trait] + impl AgentContextReloadPort for FakeAgentRuntimePorts { + async fn reload_session_context( + &self, + request: AgentContextReloadRequest, + ) -> PortResult<()> { + self.context_reloads.lock().unwrap().push(request); + Ok(()) + } + } + #[derive(Debug, Default)] struct RecordingRuntimeEventSink { events: Mutex>, @@ -2330,6 +2641,34 @@ mod tests { assert_eq!(err, RuntimeBuildError::MissingSubmissionPort); } + #[tokio::test] + async fn context_reload_is_optional_and_delegates_to_the_registered_owner() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + let request = AgentContextReloadRequest { + session_id: "session-reload".to_string(), + target: AgentContextReloadTarget::Skills, + }; + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .build() + .expect("runtime"); + assert_eq!( + runtime.reload_context(request.clone()).await.unwrap_err(), + RuntimeError::MissingContextReloadPort + ); + + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .with_context_reload_port(ports.clone()) + .build() + .expect("runtime with context owner"); + runtime + .reload_context(request.clone()) + .await + .expect("reload delegates"); + assert_eq!(*ports.context_reloads.lock().unwrap(), vec![request]); + } + #[tokio::test] async fn session_compaction_requires_registered_port() { let ports = Arc::new(FakeAgentRuntimePorts::default()); @@ -2386,6 +2725,34 @@ mod tests { ); } + #[tokio::test] + async fn session_compaction_identity_mismatch_is_outcome_unknown() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + *ports.compaction_result_identity.lock().unwrap() = + Some(("other-session".to_string(), "other-turn".to_string())); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .with_session_compaction_port(ports) + .build() + .expect("runtime"); + + let error = runtime + .start_session_compaction(AgentSessionCompactionRequest { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + }) + .await + .expect_err("provider identity mismatch must fail closed"); + + assert!(matches!( + error, + RuntimeError::Port(PortError { + kind: PortErrorKind::OutcomeUnknown, + .. + }) + )); + } + #[tokio::test] async fn session_fork_before_turn_forwards_exact_boundary_identity() { let ports = Arc::new(FakeAgentRuntimePorts::default()); @@ -2438,6 +2805,37 @@ mod tests { assert_eq!(ports.undo_requests.lock().unwrap().as_slice(), &[request]); } + #[tokio::test] + async fn session_revert_identity_mismatch_is_outcome_unknown() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + *ports.revert_result_identity.lock().unwrap() = + Some(("other-session".to_string(), "other-transcript".to_string())); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .with_session_revert_port(ports) + .build() + .expect("runtime"); + let request = AgentSessionRevertRequest { + workspace_path: "/workspace/project".to_string(), + session_id: "session-1".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }; + + for result in [ + runtime.undo_session(request.clone()).await, + runtime.redo_session(request.clone()).await, + ] { + assert!(matches!( + result.expect_err("provider identity mismatch must fail closed"), + RuntimeError::Port(PortError { + kind: PortErrorKind::OutcomeUnknown, + .. + }) + )); + } + } + #[tokio::test] async fn builder_keeps_plugin_runtime_disabled_by_default() { let ports = Arc::new(FakeAgentRuntimePorts::default()); @@ -2537,7 +2935,7 @@ mod tests { } #[tokio::test] - async fn create_session_with_id_rejects_provider_identity_mismatch() { + async fn create_session_with_id_identity_mismatch_is_outcome_unknown() { let ports = Arc::new(FakeAgentRuntimePorts::default()); *ports.exact_session_result_id.lock().unwrap() = Some("other-session-id".to_string()); let runtime = AgentRuntimeBuilder::new() @@ -2567,7 +2965,44 @@ mod tests { assert!(matches!( error, RuntimeError::Port(PortError { - kind: PortErrorKind::Backend, + kind: PortErrorKind::OutcomeUnknown, + .. + }) + )); + } + + #[tokio::test] + async fn create_transient_session_with_id_identity_mismatch_is_outcome_unknown() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + *ports.exact_session_result_id.lock().unwrap() = Some("other-session-id".to_string()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports) + .build() + .expect("runtime"); + + let error = runtime + .create_transient_session_with_id( + "fixed-session-id".to_string(), + AgentSessionCreateRequest { + session_name: "Transient session".to_string(), + agent_type: "agentic".to_string(), + workspace_path: Some("/workspace/project".to_string()), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + model_id: None, + metadata: serde_json::Map::new(), + }, + ) + .await + .expect_err("runtime must reject a transient provider identity mismatch"); + + assert!(matches!( + error, + RuntimeError::Port(PortError { + kind: PortErrorKind::OutcomeUnknown, .. }) )); @@ -2641,6 +3076,36 @@ mod tests { ); } + #[tokio::test] + async fn submit_turn_identity_mismatch_is_outcome_unknown() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + *ports.submission_result_turn_id.lock().unwrap() = Some("other-turn".to_string()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports) + .build() + .expect("runtime"); + + let error = runtime + .submit_turn(AgentSubmissionRequest { + session_id: "session-1".to_string(), + message: "hello".to_string(), + turn_id: Some("turn-1".to_string()), + source: Some(AgentSubmissionSource::Cli), + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }) + .await + .expect_err("provider identity mismatch must fail closed"); + + assert!(matches!( + error, + RuntimeError::Port(PortError { + kind: PortErrorKind::OutcomeUnknown, + .. + }) + )); + } + #[tokio::test] async fn cancel_turn_requires_registered_cancellation_port() { let ports = Arc::new(FakeAgentRuntimePorts::default()); @@ -2698,6 +3163,39 @@ mod tests { ); } + #[tokio::test] + async fn cancellation_identity_mismatch_is_outcome_unknown() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + *ports.cancellation_result_identity.lock().unwrap() = + Some(("other-session".to_string(), Some("other-turn".to_string()))); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .with_cancellation_port(ports) + .build() + .expect("runtime"); + + let error = runtime + .cancel_turn(AgentTurnCancellationRequest { + session_id: "session-1".to_string(), + turn_id: Some("turn-1".to_string()), + source: None, + requester_session_id: None, + reason: None, + wait_timeout_ms: None, + cancel_descendants: true, + }) + .await + .expect_err("provider identity mismatch must fail closed"); + + assert!(matches!( + error, + RuntimeError::Port(PortError { + kind: PortErrorKind::OutcomeUnknown, + .. + }) + )); + } + #[tokio::test] async fn session_management_requires_registered_port() { let ports = Arc::new(FakeAgentRuntimePorts::default()); @@ -2852,6 +3350,36 @@ mod tests { assert_eq!(requests[0].metadata["kind"], "usage_report"); } + #[tokio::test] + async fn local_command_turn_identity_mismatch_is_outcome_unknown() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + *ports.local_command_result_turn_id.lock().unwrap() = Some("other-turn".to_string()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .with_local_command_turn_port(ports) + .build() + .expect("runtime"); + + let error = runtime + .record_completed_local_command_turn(AgentLocalCommandTurnRecordRequest { + session_id: "session-1".to_string(), + content: "report".to_string(), + turn_id: Some("turn-1".to_string()), + timestamp_ms: None, + metadata: serde_json::Map::new(), + }) + .await + .expect_err("provider identity mismatch must fail closed"); + + assert!(matches!( + error, + RuntimeError::Port(PortError { + kind: PortErrorKind::OutcomeUnknown, + .. + }) + )); + } + #[tokio::test] async fn user_shell_command_requires_registered_port() { let ports = Arc::new(FakeAgentRuntimePorts::default()); @@ -2910,6 +3438,48 @@ mod tests { ); } + #[tokio::test] + async fn user_shell_command_identity_mismatch_is_outcome_unknown() { + #[derive(Debug)] + struct MismatchedUserShellCommandPort; + + #[async_trait::async_trait] + impl AgentUserShellCommandPort for MismatchedUserShellCommandPort { + async fn run_user_shell_command( + &self, + request: AgentUserShellCommandRequest, + ) -> PortResult { + Ok(AgentUserShellCommandResult { + session_id: request.session_id, + turn_id: "different-turn".to_string(), + }) + } + } + + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(Arc::new(FakeAgentRuntimePorts::default())) + .with_user_shell_command_port(Arc::new(MismatchedUserShellCommandPort)) + .build() + .expect("runtime"); + + let error = runtime + .run_user_shell_command(AgentUserShellCommandRequest { + session_id: "session_1".to_string(), + turn_id: "turn_1".to_string(), + command: "git status --short".to_string(), + }) + .await + .expect_err("provider identity mismatch must remain outcome unknown"); + + assert!(matches!( + error, + RuntimeError::Port(PortError { + kind: PortErrorKind::OutcomeUnknown, + .. + }) + )); + } + #[tokio::test] async fn thread_goal_management_requires_registered_port() { let ports = Arc::new(FakeAgentRuntimePorts::default()); @@ -3117,6 +3687,41 @@ mod tests { assert_eq!(ports.cancelled_turns.lock().unwrap().len(), 1); } + #[tokio::test] + async fn lineage_cancellation_identity_mismatch_is_outcome_unknown() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + *ports.cancellation_result_identity.lock().unwrap() = + Some(("child_1".to_string(), Some("other-turn".to_string()))); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .with_session_lineage_port(ports) + .build() + .expect("runtime"); + + let error = runtime + .cancel_lineage_session(AgentSessionLineageCancellationRequest { + workspace_path: "/workspace/project".to_string(), + root_session_id: "root_1".to_string(), + session_id: "child_1".to_string(), + expected_active_turn_id: Some("child_turn".to_string()), + source: Some(AgentSubmissionSource::Cli), + reason: None, + wait_timeout_ms: None, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .expect_err("provider identity mismatch must fail closed"); + + assert!(matches!( + error, + RuntimeError::Port(PortError { + kind: PortErrorKind::OutcomeUnknown, + .. + }) + )); + } + #[tokio::test] async fn session_mode_update_delegates_to_registered_port() { let ports = Arc::new(FakeAgentRuntimePorts::default()); @@ -3194,6 +3799,11 @@ mod tests { error: "recoverable failure".to_string(), recoverable: true, }, + transcript: SessionTranscript { + session_id: "session_1".to_string(), + messages: Vec::new(), + }, + pending_user_inputs: Vec::new(), }; let request_json = serde_json::to_value(request).expect("serialize restore request"); @@ -3227,6 +3837,156 @@ mod tests { assert_eq!(error, RuntimeError::MissingSessionRestorePort); } + #[tokio::test] + async fn session_restore_identity_mismatch_is_outcome_unknown() { + for returned_identity in [ + ("wrong-session", "session_1"), + ("session_1", "wrong-transcript-session"), + ] { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + *ports.restore_result_identity.lock().unwrap() = Some(( + returned_identity.0.to_string(), + returned_identity.1.to_string(), + )); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .with_session_restore_port(ports) + .build() + .expect("runtime"); + + let error = runtime + .restore_session(AgentSessionRestoreRequest { + workspace_path: "/workspace/project".to_string(), + session_id: "session_1".to_string(), + include_internal: false, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .unwrap_err(); + + assert!(matches!( + error, + RuntimeError::Port(PortError { + kind: PortErrorKind::OutcomeUnknown, + .. + }) + )); + } + } + + #[tokio::test] + async fn session_restore_rejects_pending_input_outside_the_active_turn() { + let cases = [ + (SessionState::Idle, "session_1", "turn-1"), + ( + SessionState::Error { + error: "failed".to_string(), + recoverable: true, + }, + "session_1", + "turn-1", + ), + ( + SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: crate::session_state::ProcessingPhase::ToolCalling, + }, + "wrong-session", + "turn-1", + ), + ( + SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: crate::session_state::ProcessingPhase::ToolCalling, + }, + "session_1", + "wrong-turn", + ), + ]; + + for (state, pending_session_id, pending_turn_id) in cases { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + *ports.restore_state.lock().unwrap() = Some(state); + ports + .restore_pending_user_inputs + .lock() + .unwrap() + .push(PendingUserInput { + tool_id: "question-1".to_string(), + session_id: pending_session_id.to_string(), + turn_id: pending_turn_id.to_string(), + source_session_id: "child-session".to_string(), + source_turn_id: "child-turn".to_string(), + registration_sequence: 1, + input: serde_json::json!({ "questions": [] }), + }); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .with_session_restore_port(ports) + .build() + .expect("runtime"); + + let error = runtime + .restore_session(AgentSessionRestoreRequest { + workspace_path: "/workspace/project".to_string(), + session_id: "session_1".to_string(), + include_internal: false, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .unwrap_err(); + + assert!(matches!( + error, + RuntimeError::Port(PortError { + kind: PortErrorKind::OutcomeUnknown, + .. + }) + )); + } + + let ports = Arc::new(FakeAgentRuntimePorts::default()); + *ports.restore_state.lock().unwrap() = Some(SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: crate::session_state::ProcessingPhase::ToolCalling, + }); + ports + .restore_pending_user_inputs + .lock() + .unwrap() + .push(PendingUserInput { + tool_id: "question-1".to_string(), + session_id: "session_1".to_string(), + turn_id: "turn-1".to_string(), + source_session_id: "child-session".to_string(), + source_turn_id: "child-turn".to_string(), + registration_sequence: 1, + input: serde_json::json!({ "questions": [] }), + }); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .with_session_restore_port(ports) + .build() + .expect("runtime"); + assert_eq!( + runtime + .restore_session(AgentSessionRestoreRequest { + workspace_path: "/workspace/project".to_string(), + session_id: "session_1".to_string(), + include_internal: false, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .expect("matching active input") + .pending_user_inputs + .len(), + 1 + ); + } + #[test] fn event_subscription_requires_configured_source() { let ports = Arc::new(FakeAgentRuntimePorts::default()); @@ -3427,13 +4187,66 @@ mod tests { assert!(matches!( error, RuntimeError::Port(PortError { - kind: PortErrorKind::Backend, + kind: PortErrorKind::OutcomeUnknown, .. }) )); assert!(error.into_message().contains("requested-session")); } + #[tokio::test] + async fn submit_dialog_turn_rejects_provider_turn_identity_mismatch() { + #[derive(Debug)] + struct MismatchedDialogTurnPort; + + #[async_trait::async_trait] + impl bitfun_runtime_ports::AgentDialogTurnPort for MismatchedDialogTurnPort { + async fn submit_dialog_turn( + &self, + request: AgentDialogTurnRequest, + ) -> PortResult { + Ok(DialogSubmitOutcome::Started { + session_id: request.session_id, + turn_id: "different-turn".to_string(), + }) + } + } + + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(Arc::new(FakeAgentRuntimePorts::default())) + .with_dialog_turn_port(Arc::new(MismatchedDialogTurnPort)) + .build() + .expect("runtime"); + + let error = runtime + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: "session-1".to_string(), + message: "hello".to_string(), + original_message: None, + turn_id: Some("turn-1".to_string()), + execution: Default::default(), + agent_type: "agentic".to_string(), + workspace_path: Some("/workspace/project".to_string()), + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }) + .await + .expect_err("provider turn mismatch must fail closed"); + + assert!(matches!( + error, + RuntimeError::Port(PortError { + kind: PortErrorKind::OutcomeUnknown, + .. + }) + )); + } + #[tokio::test] async fn steer_dialog_turn_requires_registered_dialog_turn_port() { let runtime = AgentRuntimeBuilder::new() @@ -3569,7 +4382,7 @@ mod tests { assert!(matches!( error, RuntimeError::Port(PortError { - kind: PortErrorKind::Backend, + kind: PortErrorKind::OutcomeUnknown, .. }) )); diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index 599de6f96a..814dbc6283 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -8,7 +8,7 @@ use std::sync::Arc; -pub const AGENT_RUNTIME_SDK_API_VERSION: u32 = 6; +pub const AGENT_RUNTIME_SDK_API_VERSION: u32 = 7; #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] @@ -51,6 +51,7 @@ pub use crate::runtime::{ SessionSelector, }; pub use crate::session_state::{session_state_label_for_state, ProcessingPhase, SessionState}; +pub use crate::user_questions::{AskUserQuestionInput, PendingUserInput}; pub use bitfun_agent_tools::{ToolRegistry, ToolRegistryItem}; pub use bitfun_core_types::SessionUsageReport; // Event envelope types re-exported so protocol surfaces (e.g. `bitfun-app-server`) @@ -63,8 +64,9 @@ pub use bitfun_harness::{ HarnessRegistry, HarnessWorkflow, }; pub use bitfun_runtime_ports::{ - AgentBackgroundResultRequest, AgentContextReloadPort, AgentDialogSteerRequest, - AgentDialogTurnExecution, AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, + AgentBackgroundResultRequest, AgentContextReloadPort, AgentContextReloadRequest, + AgentContextReloadTarget, AgentDialogSteerRequest, AgentDialogTurnExecution, + AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentInteractionResponsePort, AgentLifecycleDeliveryPort, AgentLocalCommandTurnPort, AgentLocalCommandTurnRecordRequest, AgentLocalCommandTurnRecordResult, AgentMessageWorkspaceReferencesRequest, AgentSessionArchiveRequest, @@ -152,6 +154,11 @@ impl AgentRuntimeBuilder { self } + pub fn with_context_reload_port(mut self, port: Arc) -> Self { + self.inner = self.inner.with_context_reload_port(port); + self + } + pub fn with_workspace_reference_port( mut self, port: Arc, @@ -315,6 +322,13 @@ impl AgentRuntimeBuilder { } impl AgentRuntime { + pub async fn reload_context( + &self, + request: AgentContextReloadRequest, + ) -> Result<(), RuntimeError> { + self.inner.reload_context(request).await + } + pub fn subscribe_events(&self) -> Result { self.inner.subscribe_events() } diff --git a/src/crates/execution/agent-runtime/src/user_questions.rs b/src/crates/execution/agent-runtime/src/user_questions.rs index 818048e709..6f84d5dbce 100644 --- a/src/crates/execution/agent-runtime/src/user_questions.rs +++ b/src/crates/execution/agent-runtime/src/user_questions.rs @@ -1,12 +1,17 @@ //! Portable contracts for user-question tool handlers. -use dashmap::DashMap; +use dashmap::{mapref::entry::Entry, DashMap}; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::sync::{Arc, LazyLock}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, LazyLock, +}; use tokio::sync::oneshot; +pub use bitfun_runtime_ports::PendingUserInput; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct QuestionOption { pub label: String, @@ -42,12 +47,48 @@ pub struct UserInputResponse { pub enum UserInputSendError { #[error("Waiting channel not found: {tool_id}")] MissingChannel { tool_id: String }, + #[error("Waiting channel identity does not match: {tool_id}")] + IdentityMismatch { tool_id: String }, #[error("Channel closed, cannot send answer: {tool_id}")] ChannelClosed { tool_id: String }, } pub struct UserInputManager { - channels: Arc>>, + channels: Arc>, + next_registration_sequence: AtomicU64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct PendingUserInputKey { + session_id: String, + turn_id: String, + tool_id: String, + registration_sequence: u64, +} + +impl PendingUserInputKey { + fn from_pending(pending: &PendingUserInput) -> Self { + Self { + session_id: pending.session_id.clone(), + turn_id: pending.turn_id.clone(), + tool_id: pending.tool_id.clone(), + registration_sequence: pending.registration_sequence, + } + } + + fn from_request(request: &bitfun_runtime_ports::AgentUserAnswersRequest) -> Self { + Self { + session_id: request.session_id.clone(), + turn_id: request.turn_id.clone(), + tool_id: request.tool_id.clone(), + registration_sequence: request.registration_sequence, + } + } +} + +struct PendingUserInputEntry { + pending: PendingUserInput, + sender: oneshot::Sender, } impl Default for UserInputManager { @@ -60,38 +101,101 @@ impl UserInputManager { pub fn new() -> Self { Self { channels: Arc::new(DashMap::new()), + next_registration_sequence: AtomicU64::new(0), } } - pub fn register_channel(&self, tool_id: String, sender: oneshot::Sender) { - debug!("Registered waiting channel: tool_id={}", tool_id); - self.channels.insert(tool_id, sender); + pub fn register( + &self, + mut pending: PendingUserInput, + sender: oneshot::Sender, + ) -> u64 { + pending.registration_sequence = self + .next_registration_sequence + .fetch_add(1, Ordering::Relaxed) + .saturating_add(1); + let registration_sequence = pending.registration_sequence; + debug!("Registered waiting channel: tool_id={}", pending.tool_id); + let key = PendingUserInputKey::from_pending(&pending); + self.channels + .insert(key, PendingUserInputEntry { pending, sender }); + registration_sequence + } + + /// Compatibility entry point for hosts that only need the answer channel. + /// Runtime-owned interactive tools should use [`Self::register`] so restore + /// snapshots retain the authoritative question payload and Session route. + pub fn register_channel( + &self, + tool_id: String, + sender: oneshot::Sender, + ) -> u64 { + self.register( + PendingUserInput { + tool_id, + session_id: "unknown".to_string(), + turn_id: "unknown".to_string(), + source_session_id: "unknown".to_string(), + source_turn_id: "unknown".to_string(), + registration_sequence: 0, + input: serde_json::json!({ "questions": [] }), + }, + sender, + ) } - pub fn send_answer(&self, tool_id: &str, answers: Value) -> Result<(), UserInputSendError> { - info!("Sending user answer: tool_id={}", tool_id); - - if let Some((_, sender)) = self.channels.remove(tool_id) { - let response = UserInputResponse { answers }; - sender - .send(response) - .map_err(|_| UserInputSendError::ChannelClosed { - tool_id: tool_id.to_string(), - })?; - debug!("Answer sent: tool_id={}", tool_id); - Ok(()) - } else { - let error = UserInputSendError::MissingChannel { - tool_id: tool_id.to_string(), - }; - warn!("{}", error); - Err(error) - } + pub fn send_answer( + &self, + request: bitfun_runtime_ports::AgentUserAnswersRequest, + ) -> Result<(), UserInputSendError> { + info!("Sending user answer: tool_id={}", request.tool_id); + + let key = PendingUserInputKey::from_request(&request); + let entry = match self.channels.entry(key) { + Entry::Occupied(entry) => entry, + Entry::Vacant(vacant) => { + drop(vacant); + let error = if self + .channels + .iter() + .any(|entry| entry.key().tool_id == request.tool_id) + { + UserInputSendError::IdentityMismatch { + tool_id: request.tool_id, + } + } else { + UserInputSendError::MissingChannel { + tool_id: request.tool_id, + } + }; + warn!("{}", error); + return Err(error); + } + }; + + let entry = entry.remove(); + entry + .sender + .send(UserInputResponse { + answers: request.answers, + }) + .map_err(|_| UserInputSendError::ChannelClosed { + tool_id: request.tool_id.clone(), + })?; + debug!("Answer sent: tool_id={}", request.tool_id); + Ok(()) } - pub fn cancel(&self, tool_id: &str) -> bool { - if self.channels.remove(tool_id).is_some() { - debug!("Cancelled waiting: tool_id={}", tool_id); + pub fn cancel_registration(&self, pending: &PendingUserInput) -> bool { + let key = PendingUserInputKey::from_pending(pending); + if self.channels.remove(&key).is_some() { + debug!( + "Cancelled waiting registration: session_id={}, turn_id={}, tool_id={}, registration_sequence={}", + pending.session_id, + pending.turn_id, + pending.tool_id, + pending.registration_sequence + ); true } else { false @@ -99,14 +203,37 @@ impl UserInputManager { } pub fn has_pending(&self, tool_id: &str) -> bool { - self.channels.contains_key(tool_id) + self.remove_closed_channels(); + self.channels + .iter() + .any(|entry| entry.key().tool_id == tool_id) } pub fn pending_tool_ids(&self) -> Vec { - self.channels + self.remove_closed_channels(); + let mut tool_ids = self + .channels + .iter() + .map(|entry| entry.key().tool_id.clone()) + .collect::>(); + tool_ids.sort(); + tool_ids.dedup(); + tool_ids + } + + pub fn pending_inputs(&self) -> Vec { + self.remove_closed_channels(); + let mut pending = self + .channels .iter() - .map(|entry| entry.key().clone()) - .collect() + .map(|entry| entry.pending.clone()) + .collect::>(); + pending.sort_by_key(|input| input.registration_sequence); + pending + } + + fn remove_closed_channels(&self) { + self.channels.retain(|_, entry| !entry.sender.is_closed()); } } @@ -261,7 +388,11 @@ fn format_result_for_assistant(questions: &[Question], answers: &Value) -> Strin #[cfg(test)] mod tests { - use super::{UserInputManager, UserInputResponse, UserInputSendError}; + use super::{ + AskUserQuestionInput, PendingUserInput, Question, UserInputManager, UserInputResponse, + UserInputSendError, + }; + use bitfun_runtime_ports::AgentUserAnswersRequest; use serde_json::json; #[tokio::test] @@ -269,10 +400,16 @@ mod tests { let manager = UserInputManager::new(); let (sender, receiver) = tokio::sync::oneshot::channel::(); - manager.register_channel("tool-1".to_string(), sender); + let registration_sequence = manager.register(pending_input("tool-1"), sender); assert!(manager.has_pending("tool-1")); manager - .send_answer("tool-1", json!({ "0": "yes" })) + .send_answer(answer_request( + "session-1", + "turn-1", + "tool-1", + registration_sequence, + json!({ "0": "yes" }), + )) .expect("answer should be sent"); let response = receiver.await.expect("receiver should get answer"); @@ -281,22 +418,144 @@ mod tests { } #[tokio::test] - async fn user_input_manager_cancel_closes_receiver() { + async fn identity_mismatch_does_not_consume_the_current_answer_channel() { let manager = UserInputManager::new(); let (sender, receiver) = tokio::sync::oneshot::channel::(); + let registration_sequence = manager.register(pending_input("shared-tool"), sender); + + let mismatch = manager + .send_answer(AgentUserAnswersRequest { + session_id: "stale-session".to_string(), + turn_id: "turn-1".to_string(), + tool_id: "shared-tool".to_string(), + registration_sequence, + answers: json!({ "0": "stale" }), + }) + .expect_err("a stale Session identity must be rejected"); + assert!(matches!( + mismatch, + UserInputSendError::IdentityMismatch { ref tool_id } + if tool_id == "shared-tool" + )); + assert!(manager.has_pending("shared-tool")); + + manager + .send_answer(AgentUserAnswersRequest { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + tool_id: "shared-tool".to_string(), + registration_sequence, + answers: json!({ "0": "current" }), + }) + .expect("the exact registered identity should still be answerable"); + + let response = receiver + .await + .expect("receiver should get the current answer"); + assert_eq!(response.answers, json!({ "0": "current" })); + assert!(!manager.has_pending("shared-tool")); + } + + #[tokio::test] + async fn concurrent_sessions_can_wait_on_the_same_provider_tool_id() { + let manager = UserInputManager::new(); + let (sender_a, receiver_a) = tokio::sync::oneshot::channel::(); + let (sender_b, receiver_b) = tokio::sync::oneshot::channel::(); + let mut pending_a = pending_input("shared-tool"); + pending_a.session_id = "session-a".to_string(); + pending_a.turn_id = "turn-a".to_string(); + let mut pending_b = pending_input("shared-tool"); + pending_b.session_id = "session-b".to_string(); + pending_b.turn_id = "turn-b".to_string(); + + let sequence_a = manager.register(pending_a, sender_a); + let sequence_b = manager.register(pending_b, sender_b); + + let pending = manager.pending_inputs(); + assert_eq!(pending.len(), 2); + assert_eq!( + pending + .iter() + .map(|input| (input.session_id.as_str(), input.turn_id.as_str())) + .collect::>(), + vec![("session-a", "turn-a"), ("session-b", "turn-b")] + ); + + manager + .send_answer(answer_request( + "session-a", + "turn-a", + "shared-tool", + sequence_a, + json!({ "0": "answer-a" }), + )) + .expect("session A should retain its own answer channel"); + manager + .send_answer(answer_request( + "session-b", + "turn-b", + "shared-tool", + sequence_b, + json!({ "0": "answer-b" }), + )) + .expect("session B should retain its own answer channel"); + + assert_eq!( + receiver_a.await.expect("session A receiver").answers, + json!({ "0": "answer-a" }) + ); + assert_eq!( + receiver_b.await.expect("session B receiver").answers, + json!({ "0": "answer-b" }) + ); + } - manager.register_channel("tool-1".to_string(), sender); + #[tokio::test] + async fn user_input_manager_cancel_closes_only_the_exact_registration() { + let manager = UserInputManager::new(); + let (sender_a, receiver_a) = tokio::sync::oneshot::channel::(); + let (sender_b, receiver_b) = tokio::sync::oneshot::channel::(); + let mut pending_a = pending_input("shared-tool"); + pending_a.session_id = "session-a".to_string(); + pending_a.turn_id = "turn-a".to_string(); + let mut pending_b = pending_input("shared-tool"); + pending_b.session_id = "session-b".to_string(); + pending_b.turn_id = "turn-b".to_string(); + + pending_a.registration_sequence = manager.register(pending_a.clone(), sender_a); + pending_b.registration_sequence = manager.register(pending_b.clone(), sender_b); + + assert!(manager.cancel_registration(&pending_a)); + assert!(receiver_a.await.is_err()); + assert!(!manager.cancel_registration(&pending_a)); + assert!(manager.has_pending("shared-tool")); - assert!(manager.cancel("tool-1")); - assert!(receiver.await.is_err()); - assert!(!manager.cancel("tool-1")); + manager + .send_answer(answer_request( + "session-b", + "turn-b", + "shared-tool", + pending_b.registration_sequence, + json!({ "0": "answer-b" }), + )) + .expect("cancelling session A must not consume session B"); + assert_eq!( + receiver_b.await.expect("session B receiver").answers, + json!({ "0": "answer-b" }) + ); } #[tokio::test] async fn user_input_manager_distinguishes_missing_and_closed_channels() { let manager = UserInputManager::new(); let missing = manager - .send_answer("missing-tool", json!({ "0": "yes" })) + .send_answer(answer_request( + "session-1", + "turn-1", + "missing-tool", + 1, + json!({ "0": "yes" }), + )) .expect_err("missing channel"); assert_eq!( missing, @@ -306,10 +565,16 @@ mod tests { ); let (sender, receiver) = tokio::sync::oneshot::channel::(); - manager.register_channel("closed-tool".to_string(), sender); + let registration_sequence = manager.register(pending_input("closed-tool"), sender); drop(receiver); let closed = manager - .send_answer("closed-tool", json!({ "0": "yes" })) + .send_answer(answer_request( + "session-1", + "turn-1", + "closed-tool", + registration_sequence, + json!({ "0": "yes" }), + )) .expect_err("closed channel"); assert_eq!( closed, @@ -324,8 +589,77 @@ mod tests { let manager = UserInputManager::new(); let (sender, _receiver) = tokio::sync::oneshot::channel::(); - manager.register_channel("tool-1".to_string(), sender); + manager.register(pending_input("tool-1"), sender); assert_eq!(manager.pending_tool_ids(), vec!["tool-1".to_string()]); } + + #[test] + fn user_input_manager_reports_authoritative_pending_inputs_in_registration_order() { + let manager = UserInputManager::new(); + let (sender_2, _receiver_2) = tokio::sync::oneshot::channel::(); + let (sender_1, _receiver_1) = tokio::sync::oneshot::channel::(); + + manager.register(pending_input("tool-2"), sender_2); + manager.register(pending_input("tool-1"), sender_1); + + let pending = manager.pending_inputs(); + assert_eq!( + pending + .iter() + .map(|input| input.tool_id.as_str()) + .collect::>(), + vec!["tool-2", "tool-1"] + ); + assert!(pending[0].registration_sequence < pending[1].registration_sequence); + } + + #[test] + fn closed_user_input_channels_are_not_restored_as_pending() { + let manager = UserInputManager::new(); + let (sender, receiver) = tokio::sync::oneshot::channel::(); + manager.register(pending_input("cancelled-tool"), sender); + + drop(receiver); + + assert!(manager.pending_inputs().is_empty()); + assert!(!manager.has_pending("cancelled-tool")); + assert!(manager.pending_tool_ids().is_empty()); + } + + fn pending_input(tool_id: &str) -> PendingUserInput { + PendingUserInput { + tool_id: tool_id.to_string(), + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + source_session_id: "session-1".to_string(), + source_turn_id: "turn-1".to_string(), + registration_sequence: 0, + input: serde_json::to_value(AskUserQuestionInput { + questions: vec![Question { + question: "Continue?".to_string(), + header: "Confirm".to_string(), + options: Vec::new(), + multi_select: false, + }], + }) + .expect("serialize pending input"), + } + } + + fn answer_request( + session_id: &str, + turn_id: &str, + tool_id: &str, + registration_sequence: u64, + answers: serde_json::Value, + ) -> AgentUserAnswersRequest { + AgentUserAnswersRequest { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + tool_id: tool_id.to_string(), + registration_sequence, + answers, + } + } } diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts/interaction_response_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts/interaction_response_contracts.rs index 94db17950a..c455ec8d5e 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts/interaction_response_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts/interaction_response_contracts.rs @@ -63,7 +63,10 @@ async fn sdk_forwards_typed_user_answers_without_losing_payloads() { .expect("runtime with interaction response port"); let answers = AgentUserAnswersRequest { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), tool_id: "tool-3".to_string(), + registration_sequence: 7, answers: json!({ "choice": "continue", "notes": ["keep history"] }), }; @@ -87,7 +90,10 @@ async fn sdk_reports_a_missing_interaction_response_port() { let error = runtime .submit_user_answers(AgentUserAnswersRequest { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), tool_id: "tool-3".to_string(), + registration_sequence: 7, answers: json!({ "choice": "continue" }), }) .await @@ -100,12 +106,18 @@ async fn sdk_reports_a_missing_interaction_response_port() { fn interaction_response_requests_keep_camel_case_wire_fields() { assert_eq!( serde_json::to_value(AgentUserAnswersRequest { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), tool_id: "tool-3".to_string(), + registration_sequence: 7, answers: json!({ "choice": "continue" }), }) .expect("serialize user answers request"), json!({ + "sessionId": "session-1", + "turnId": "turn-1", "toolId": "tool-3", + "registrationSequence": 7, "answers": { "choice": "continue" }, }) ); diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs index 14d841523e..d2df2a66bb 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs @@ -3,7 +3,8 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use bitfun_agent_runtime::sdk::{ - build_descriptor_harness_registry, AgentEventStream, AgentRunRequest, AgentRuntimeBuilder, + build_descriptor_harness_registry, AgentContextReloadPort, AgentContextReloadRequest, + AgentContextReloadTarget, AgentEventStream, AgentRunRequest, AgentRuntimeBuilder, AgentRuntimeSdkCompatibility, AgentRuntimeSdkStability, AgentSessionClosePort, AgentSessionCreateRequest, AgentSessionCreateResult, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, @@ -49,11 +50,16 @@ struct FakeSessionClosePort { requests: Mutex>, } +#[derive(Debug, Default)] +struct FakeContextReloadPort { + requests: Mutex>, +} + #[test] fn sdk_facade_exposes_versioned_preview_compatibility_contract() { let compatibility = AgentRuntimeSdkCompatibility::current(); - assert_eq!(compatibility.api_version, 6); + assert_eq!(compatibility.api_version, 7); assert_eq!(compatibility.crate_version, env!("CARGO_PKG_VERSION")); assert_eq!(compatibility.stability, AgentRuntimeSdkStability::Preview); } @@ -122,6 +128,14 @@ impl AgentSessionClosePort for FakeSessionClosePort { } } +#[async_trait] +impl AgentContextReloadPort for FakeContextReloadPort { + async fn reload_session_context(&self, request: AgentContextReloadRequest) -> PortResult<()> { + self.requests.lock().unwrap().push(request); + Ok(()) + } +} + #[async_trait] impl SessionStorePort for FakeSdkRuntimePort { async fn resolve_session_storage_path( @@ -420,3 +434,32 @@ async fn sdk_facade_reports_missing_session_close_capability() { "agent session close port is not registered" ); } + +#[tokio::test] +async fn sdk_facade_exports_and_delegates_context_reload_contract() { + let request = AgentContextReloadRequest { + session_id: "sdk-session-1".to_string(), + target: AgentContextReloadTarget::Skills, + }; + let without_owner = AgentRuntimeBuilder::new() + .with_submission_port(Arc::new(FakeSdkAgentProvider::default())) + .build() + .expect("sdk runtime without context owner"); + assert!(matches!( + without_owner.reload_context(request.clone()).await, + Err(RuntimeError::MissingContextReloadPort) + )); + + let owner = Arc::new(FakeContextReloadPort::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(Arc::new(FakeSdkAgentProvider::default())) + .with_context_reload_port(owner.clone()) + .build() + .expect("sdk runtime with context owner"); + runtime + .reload_context(request.clone()) + .await + .expect("delegate context reload through SDK facade"); + + assert_eq!(owner.requests.lock().unwrap().as_slice(), &[request]); +} diff --git a/src/crates/interfaces/acp/src/runtime/events.rs b/src/crates/interfaces/acp/src/runtime/events.rs index 35bab00709..7efcca97b7 100644 --- a/src/crates/interfaces/acp/src/runtime/events.rs +++ b/src/crates/interfaces/acp/src/runtime/events.rs @@ -256,6 +256,22 @@ fn tool_call_update(tool_event: &ToolEventData) -> Option { .locations(tool_locations(effective_input)) .raw_input(sanitize_tool_input(tool_name, effective_input.clone())) } + ToolEventData::UserInputRequested { + identity, params, .. + } => { + let (tool_name, effective_input) = + bitfun_agent_tools::effective_tool_invocation(&identity.tool_name, params); + ToolCallUpdateFields::new() + .title(tool_title(tool_name)) + .kind(tool_kind(tool_name)) + .status(ToolCallStatus::InProgress) + .locations(tool_locations(effective_input)) + .raw_input(sanitize_tool_input(tool_name, effective_input.clone())) + .content(vec![text_content("Waiting for user input.")]) + } + ToolEventData::UserInputResolved { .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::InProgress) + .content(vec![text_content("User input received.")]), ToolEventData::Progress { message, percentage, @@ -614,6 +630,30 @@ mod tests { ); } + #[test] + fn user_input_requested_updates_the_existing_tool_call() { + let mut seen = HashSet::new(); + let started = ToolEventData::Started { + identity: identity("AskUserQuestion"), + params: serde_json::json!({ "questions": [{ "question": "Continue?" }] }), + timeout_seconds: None, + }; + assert_eq!(tool_event_updates(&started, &mut seen).len(), 2); + + let waiting = ToolEventData::UserInputRequested { + identity: identity("AskUserQuestion"), + registration_sequence: 1, + params: serde_json::json!({ "questions": [{ "question": "Continue?" }] }), + }; + let updates = tool_event_updates(&waiting, &mut seen); + + assert_eq!(updates.len(), 1, "must not announce a second tool call"); + let SessionUpdate::ToolCallUpdate(update) = &updates[0] else { + panic!("expected tool call update"); + }; + assert_eq!(update.fields.status, Some(ToolCallStatus::InProgress)); + } + #[test] fn deferred_write_started_projects_effective_identity_and_input() { let mut seen = HashSet::new(); diff --git a/src/crates/interfaces/app-server-protocol/src/lib.rs b/src/crates/interfaces/app-server-protocol/src/lib.rs index dc16279890..cd2fbef460 100644 --- a/src/crates/interfaces/app-server-protocol/src/lib.rs +++ b/src/crates/interfaces/app-server-protocol/src/lib.rs @@ -21,17 +21,18 @@ pub use schemas::{ }; /// Current App Server protocol version. -pub const PROTOCOL_VERSION: u32 = 3; +pub const PROTOCOL_VERSION: u32 = 4; /// Oldest protocol version this implementation accepts. -pub const MIN_PROTOCOL_VERSION: u32 = 2; +pub const MIN_PROTOCOL_VERSION: u32 = 4; #[cfg(test)] mod protocol_version_tests { - use super::PROTOCOL_VERSION; + use super::{MIN_PROTOCOL_VERSION, PROTOCOL_VERSION}; #[test] - fn application_protocol_stays_at_version_3() { - assert_eq!(PROTOCOL_VERSION, 3); + fn application_protocol_stays_at_version_4() { + assert_eq!(PROTOCOL_VERSION, 4); + assert_eq!(MIN_PROTOCOL_VERSION, 4); } } diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs b/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs index 44cb3eae53..5a9aca26b1 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs @@ -75,7 +75,10 @@ pub struct RunUserShellCommandResponse(pub AgentUserShellCommandResult); #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] #[request(method = "agent/submitUserAnswers", response = SubmitUserAnswersResponse)] pub struct SubmitUserAnswersRequest { + pub session_id: String, + pub turn_id: String, pub tool_id: String, + pub registration_sequence: u64, pub answers: serde_json::Value, } diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/session.rs b/src/crates/interfaces/app-server-protocol/src/schemas/session.rs index 5c0d51180a..bd1f25195d 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/session.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/session.rs @@ -12,7 +12,7 @@ use bitfun_runtime_ports::{ AgentSessionModelUpdateRequest, AgentSessionRenameRequest, AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionSummary, AgentSessionUsageRequest, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentTurnCancellationResult, - AgentTurnSettlementRequest, SessionTranscript, SessionTranscriptRequest, + AgentTurnSettlementRequest, PendingUserInput, SessionTranscript, SessionTranscriptRequest, }; use serde::{Deserialize, Serialize}; @@ -46,6 +46,8 @@ pub struct SyncSessionResponse { pub workspace_binding: AgentSessionWorkspaceBinding, #[serde(default)] pub pending_permissions: Vec, + #[serde(default)] + pub pending_user_inputs: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -222,3 +224,49 @@ unit_response!(SetSessionArchivedResponse); #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[request(method = "session/forkAtTurn", response = ForkSessionResponse)] pub struct ForkSessionAtTurnMessage(pub AgentSessionForkAtTurnRequest); + +#[cfg(test)] +mod tests { + use super::SyncSessionResponse; + + #[test] + fn sync_session_round_trip_preserves_authoritative_pending_user_inputs() { + let response: SyncSessionResponse = serde_json::from_value(serde_json::json!({ + "session": { + "sessionId": "session-1", + "sessionName": "Session", + "agentType": "agentic", + "turnCount": 1, + "createdAtMs": 10, + "lastActiveAtMs": 20 + }, + "state": { + "kind": "processing", + "currentTurnId": "turn-1", + "phase": "toolCalling" + }, + "transcript": { + "sessionId": "session-1", + "messages": [] + }, + "workspaceBinding": { + "workspacePath": "/workspace" + }, + "pendingPermissions": [], + "pendingUserInputs": [{ + "toolId": "question-1", + "sessionId": "session-1", + "turnId": "turn-1", + "sourceSessionId": "child-session", + "sourceTurnId": "child-turn", + "registrationSequence": 7, + "input": { "questions": [{ "question": "Continue?" }] } + }] + })) + .expect("sync response fixture"); + + let encoded = serde_json::to_value(response).expect("serialize sync response"); + assert_eq!(encoded["pendingUserInputs"][0]["toolId"], "question-1"); + assert_eq!(encoded["pendingUserInputs"][0]["registrationSequence"], 7); + } +} diff --git a/src/crates/interfaces/app-server/AGENTS.md b/src/crates/interfaces/app-server/AGENTS.md index 6078559e7b..006d8ad645 100644 --- a/src/crates/interfaces/app-server/AGENTS.md +++ b/src/crates/interfaces/app-server/AGENTS.md @@ -64,6 +64,17 @@ Host subscription: - Connection-local sequence/cursor and sync behavior must remain explicit. Do not describe it as persisted cross-connection replay or resume unless such an owner and contract are implemented. +- Permission synchronization is connection-local and at-least-once. The + Runtime owner inserts a pending request before its audit completes and + publishes `Asked`; therefore one `request_id` may appear in the sync + snapshot and then arrive again as an event after the returned watermark. + Clients must merge Permission facts idempotently by `request_id`. The server + must preserve this possible duplicate instead of creating a snapshot/event + window that can lose the request. +- Advertise the Permission capability as available only when the injected + Runtime has a Permission request manager. Missing owners remain explicit + `Unavailable` capabilities and Permission snapshot/command calls fail + closed. ## Error Mapping diff --git a/src/crates/interfaces/app-server/Cargo.toml b/src/crates/interfaces/app-server/Cargo.toml index b2a81f826c..2d719f566a 100644 --- a/src/crates/interfaces/app-server/Cargo.toml +++ b/src/crates/interfaces/app-server/Cargo.toml @@ -14,17 +14,13 @@ bitfun-app-server-protocol = { path = "../app-server-protocol" } # Host-service handlers use the reviewed Agent Runtime owner closure. Add a # narrower Core owner feature when a newly registered domain needs it; the # protocol surface must not inherit the broad bitfun-core/product-full union. -bitfun-core = { path = "../../assembly/core", features = ["external-sources", "git", "i18n-runtime", "remote-connect"] } +bitfun-core = { path = "../../assembly/core", features = ["agent-runtime", "git", "i18n-runtime"] } bitfun-agent-runtime = { path = "../../execution/agent-runtime", features = ["agent-runtime"] } -bitfun-product-domains = { path = "../../contracts/product-domains", features = ["external-sources"] } bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["agent-api"] } tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } serde = { workspace = true } serde_json = { workspace = true } -anyhow = { workspace = true } log = { workspace = true } -tracing = { workspace = true } -url = { workspace = true } [features] default = [] diff --git a/src/crates/interfaces/app-server/src/agent.rs b/src/crates/interfaces/app-server/src/agent.rs index 519918a6f9..761949efae 100644 --- a/src/crates/interfaces/app-server/src/agent.rs +++ b/src/crates/interfaces/app-server/src/agent.rs @@ -11,8 +11,10 @@ use std::sync::Arc; use agent_client_protocol::{Error, Result}; use bitfun_agent_runtime::sdk::{AgentEventSource, AgentRuntime, PortErrorKind, RuntimeError}; +use bitfun_app_server_protocol::error::{ + AppServerErrorData, AppServerErrorKind, OUTCOME_UNKNOWN_CODE, +}; use bitfun_core::service::git::GitError; -use bitfun_runtime_ports::AgentContextReloadPort; /// Host-injected BitFun agent runtime exposed over the app-server surface. /// @@ -24,7 +26,6 @@ use bitfun_runtime_ports::AgentContextReloadPort; pub struct BitfunAppRuntime { runtime: Arc, event_source: AgentEventSource, - context_reload: Option>, } impl std::fmt::Debug for BitfunAppRuntime { @@ -43,15 +44,9 @@ impl BitfunAppRuntime { Self { runtime: Arc::new(runtime), event_source, - context_reload: None, } } - pub fn with_context_reload(mut self, context_reload: Arc) -> Self { - self.context_reload = Some(context_reload); - self - } - /// Shared reference to the underlying agent runtime, for handlers that /// need to call SDK methods directly (for example subscribing to events). pub fn runtime(&self) -> &AgentRuntime { @@ -67,19 +62,17 @@ impl BitfunAppRuntime { self.event_source.clone() } - pub fn context_reload(&self) -> Option<&Arc> { - self.context_reload.as_ref() - } - /// Map a `RuntimeError` to a JSON-RPC `Error`, mirroring the ACP runtime /// boundary: `PortErrorKind::NotFound` becomes `resource_not_found`, - /// `InvalidRequest` becomes `invalid_params`, everything else stays + /// `InvalidRequest` becomes `invalid_params`, `OutcomeUnknown` keeps the + /// stable non-retryable protocol contract, and everything else stays /// `internal_error` with the message surfaced as data. pub fn runtime_error(error: RuntimeError) -> Error { match error { RuntimeError::Port(error) => match error.kind { PortErrorKind::InvalidRequest => Error::invalid_params().data(error.message), PortErrorKind::NotFound => Error::resource_not_found(None), + PortErrorKind::OutcomeUnknown => Self::outcome_unknown(error.message), _ => Self::internal_error(error.message), }, other => Self::internal_error(other.into_message()), @@ -100,6 +93,19 @@ impl BitfunAppRuntime { fn internal_error(message: impl std::fmt::Display) -> Error { Error::internal_error().data(serde_json::json!(message.to_string())) } + + fn outcome_unknown(message: String) -> Error { + Error::new(OUTCOME_UNKNOWN_CODE as i32, message).data( + serde_json::to_value(AppServerErrorData { + kind: AppServerErrorKind::OutcomeUnknown, + retryable: false, + outcome_unknown: true, + capability: None, + request_id: None, + }) + .expect("AppServerErrorData must serialize"), + ) + } } /// Convenience for wrapping a fallible runtime call into a JSON-RPC `Result`. diff --git a/src/crates/interfaces/app-server/src/lib.rs b/src/crates/interfaces/app-server/src/lib.rs index 929a4da7cb..3a2b458778 100644 --- a/src/crates/interfaces/app-server/src/lib.rs +++ b/src/crates/interfaces/app-server/src/lib.rs @@ -6,8 +6,10 @@ //! own `JsonRpcRequest` / `JsonRpcNotification` types; the crate binds no //! schema method set, unlike [`bitfun_acp`]. //! -//! [`BitfunAppServer`] exposes the selected Agent Kernel and management -//! operations over a host-injected runtime. Behavior-light wire contracts are +//! [`BitfunAppServer`] exposes the selected Agent Kernel over a host-injected +//! runtime. Management methods are registered but remain unavailable in the +//! generic Server Host until a trusted, scope-bound owner adapter is wired. +//! Behavior-light wire contracts are //! owned by `bitfun-app-server-protocol`; [`schema`] remains a compatibility //! re-export for existing server-side imports. Typed clients are owned by the //! separate `bitfun-app-server-client` crate. @@ -51,7 +53,7 @@ #![recursion_limit = "256"] pub mod agent; -pub mod management; +mod management; pub mod role; pub mod schema; pub mod server; @@ -59,11 +61,6 @@ pub mod transport; pub use agent::BitfunAppRuntime; pub use agent_client_protocol as protocol; -pub use management::{ - AppManagementCapabilities, AppManagementError, AppManagementErrorKind, AppManagementResult, - AppManagementService, EXTERNAL_HOOKS_CAPABILITY, EXTERNAL_SOURCES_CAPABILITY, - NATIVE_HOOKS_CAPABILITY, -}; pub use role::{AppClient, AppServer}; pub use server::BitfunAppServer; diff --git a/src/crates/interfaces/app-server/src/management.rs b/src/crates/interfaces/app-server/src/management.rs index b1b4ac8e26..c60503c0de 100644 --- a/src/crates/interfaces/app-server/src/management.rs +++ b/src/crates/interfaces/app-server/src/management.rs @@ -1,57 +1,39 @@ -//! Host-injected management service and capability boundary. +//! Structured unavailable capability projection for management methods. +//! +//! No production Host currently provides these owners. Keep the wire methods +//! fail-closed; a future Host must add a scoped adapter with a real consumer. use bitfun_app_server_protocol::app::{CapabilityAvailability, CapabilityDescriptor}; -mod service; -mod worktree; - -pub use service::AppManagementService; - -pub const MODES_CAPABILITY: &str = "tui.modes"; -pub const MODELS_CAPABILITY: &str = "tui.models"; -pub const SKILLS_CAPABILITY: &str = "tui.skills"; -pub const SUBAGENTS_CAPABILITY: &str = "tui.subagents"; -pub const MCP_CAPABILITY: &str = "tui.mcp"; -pub const EXTERNAL_SOURCES_CAPABILITY: &str = "tui.externalSources"; -pub const NATIVE_HOOKS_CAPABILITY: &str = "tui.nativeHooks"; -pub const EXTERNAL_HOOKS_CAPABILITY: &str = "tui.externalHooks"; -pub const ACCOUNT_CAPABILITY: &str = "tui.account"; -pub const SETTINGS_SYNC_CAPABILITY: &str = "tui.settingsSync"; -pub const WORKTREES_CAPABILITY: &str = "tui.worktrees"; +pub(crate) const MODES_CAPABILITY: &str = "tui.modes"; +pub(crate) const MODELS_CAPABILITY: &str = "tui.models"; +pub(crate) const SKILLS_CAPABILITY: &str = "tui.skills"; +pub(crate) const SUBAGENTS_CAPABILITY: &str = "tui.subagents"; +pub(crate) const MCP_CAPABILITY: &str = "tui.mcp"; +pub(crate) const EXTERNAL_SOURCES_CAPABILITY: &str = "tui.externalSources"; +pub(crate) const NATIVE_HOOKS_CAPABILITY: &str = "tui.nativeHooks"; +pub(crate) const EXTERNAL_HOOKS_CAPABILITY: &str = "tui.externalHooks"; +pub(crate) const ACCOUNT_CAPABILITY: &str = "tui.account"; +pub(crate) const SETTINGS_SYNC_CAPABILITY: &str = "tui.settingsSync"; +pub(crate) const WORKTREES_CAPABILITY: &str = "tui.worktrees"; #[derive(Debug, Clone, PartialEq, Eq)] -pub struct AppManagementCapabilities { - pub modes: CapabilityAvailability, - pub models: CapabilityAvailability, - pub skills: CapabilityAvailability, - pub subagents: CapabilityAvailability, - pub mcp: CapabilityAvailability, - pub external_sources: CapabilityAvailability, - pub native_hooks: CapabilityAvailability, - pub external_hooks: CapabilityAvailability, - pub account: CapabilityAvailability, - pub settings_sync: CapabilityAvailability, - pub worktrees: CapabilityAvailability, +pub(crate) struct AppManagementCapabilities { + pub(crate) modes: CapabilityAvailability, + pub(crate) models: CapabilityAvailability, + pub(crate) skills: CapabilityAvailability, + pub(crate) subagents: CapabilityAvailability, + pub(crate) mcp: CapabilityAvailability, + pub(crate) external_sources: CapabilityAvailability, + pub(crate) native_hooks: CapabilityAvailability, + pub(crate) external_hooks: CapabilityAvailability, + pub(crate) account: CapabilityAvailability, + pub(crate) settings_sync: CapabilityAvailability, + pub(crate) worktrees: CapabilityAvailability, } impl AppManagementCapabilities { - pub fn available() -> Self { - Self { - modes: CapabilityAvailability::Available, - models: CapabilityAvailability::Available, - skills: CapabilityAvailability::Available, - subagents: CapabilityAvailability::Available, - mcp: CapabilityAvailability::Available, - external_sources: CapabilityAvailability::Available, - native_hooks: CapabilityAvailability::Available, - external_hooks: CapabilityAvailability::Available, - account: CapabilityAvailability::Available, - settings_sync: CapabilityAvailability::Available, - worktrees: CapabilityAvailability::Available, - } - } - - pub fn unavailable(reason: impl Into) -> Self { + pub(crate) fn unavailable(reason: impl Into) -> Self { let reason = reason.into(); Self { modes: unavailable(&reason), @@ -68,24 +50,7 @@ impl AppManagementCapabilities { } } - pub fn availability(&self, capability: &str) -> Option<&CapabilityAvailability> { - match capability { - MODES_CAPABILITY => Some(&self.modes), - MODELS_CAPABILITY => Some(&self.models), - SKILLS_CAPABILITY => Some(&self.skills), - SUBAGENTS_CAPABILITY => Some(&self.subagents), - MCP_CAPABILITY => Some(&self.mcp), - EXTERNAL_SOURCES_CAPABILITY => Some(&self.external_sources), - NATIVE_HOOKS_CAPABILITY => Some(&self.native_hooks), - EXTERNAL_HOOKS_CAPABILITY => Some(&self.external_hooks), - ACCOUNT_CAPABILITY => Some(&self.account), - SETTINGS_SYNC_CAPABILITY => Some(&self.settings_sync), - WORKTREES_CAPABILITY => Some(&self.worktrees), - _ => None, - } - } - - pub fn descriptors(&self) -> Vec { + pub(crate) fn descriptors(&self) -> Vec { vec![ descriptor(MODES_CAPABILITY, self.modes.clone(), &["agent/listModes"]), descriptor( @@ -202,52 +167,6 @@ fn descriptor( } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AppManagementErrorKind { - Unsupported, - InvalidRequest, - NotFound, - Internal, -} - -#[derive(Debug, Clone)] -pub struct AppManagementError { - pub kind: AppManagementErrorKind, - pub message: String, -} - -impl AppManagementError { - pub fn unsupported(message: impl Into) -> Self { - Self { - kind: AppManagementErrorKind::Unsupported, - message: message.into(), - } - } - - pub fn invalid_request(message: impl Into) -> Self { - Self { - kind: AppManagementErrorKind::InvalidRequest, - message: message.into(), - } - } - - pub fn not_found(message: impl Into) -> Self { - Self { - kind: AppManagementErrorKind::NotFound, - message: message.into(), - } - } - - pub fn internal(message: impl Into) -> Self { - Self { - kind: AppManagementErrorKind::Internal, - message: message.into(), - } - } -} - -pub type AppManagementResult = Result; - #[cfg(test)] mod tests { use super::*; @@ -266,83 +185,4 @@ mod tests { )); } } - - #[test] - fn hook_capabilities_are_separate_and_exclude_compiled_in_hooks() { - let capabilities = AppManagementCapabilities::available(); - let native = capabilities - .descriptors() - .into_iter() - .find(|descriptor| descriptor.id == NATIVE_HOOKS_CAPABILITY) - .expect("native Hook capability"); - assert_eq!(native.methods, ["nativeHook/overview"]); - - let external = capabilities - .descriptors() - .into_iter() - .find(|descriptor| descriptor.id == EXTERNAL_HOOKS_CAPABILITY) - .expect("external Hook capability"); - assert_eq!( - external.methods, - [ - "externalHook/snapshot", - "externalHook/plan", - "externalHook/apply", - "externalHook/mutate", - ] - ); - assert!(native - .methods - .iter() - .chain(external.methods.iter()) - .all(|method| !method.to_ascii_lowercase().contains("postcall"))); - } - - #[test] - fn account_and_settings_sync_have_separate_capabilities() { - let capabilities = AppManagementCapabilities::available().descriptors(); - let account = capabilities - .iter() - .find(|descriptor| descriptor.id == ACCOUNT_CAPABILITY) - .expect("account capability"); - assert_eq!( - account.methods, - [ - "account/snapshot", - "account/login", - "account/finalizeLogin", - "account/logout", - ] - ); - let sync = capabilities - .iter() - .find(|descriptor| descriptor.id == SETTINGS_SYNC_CAPABILITY) - .expect("settings sync capability"); - assert_eq!( - sync.methods, - [ - "settingsSync/start", - "settingsSync/snapshot", - "settingsSync/cancel", - "settingsSync/localChanged", - ] - ); - } - - #[test] - fn worktree_capability_exposes_only_typed_session_operations() { - let capabilities = AppManagementCapabilities::available().descriptors(); - let worktrees = capabilities - .iter() - .find(|descriptor| descriptor.id == WORKTREES_CAPABILITY) - .expect("worktree capability"); - assert_eq!( - worktrees.methods, - [ - "worktree/repositoryStatus", - "worktree/bindSession", - "worktree/releaseSession", - ] - ); - } } diff --git a/src/crates/interfaces/app-server/src/management/service.rs b/src/crates/interfaces/app-server/src/management/service.rs deleted file mode 100644 index 1513d809a4..0000000000 --- a/src/crates/interfaces/app-server/src/management/service.rs +++ /dev/null @@ -1,2115 +0,0 @@ -//! Concrete App Server management adapter over the existing product owners. - -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; - -use anyhow::{Context, Result}; -use bitfun_app_server_protocol::account::*; -use bitfun_app_server_protocol::agent::{ - AgentModeSummary, ListAgentModesRequest, ListAgentModesResponse, -}; -use bitfun_app_server_protocol::external_source::*; -use bitfun_app_server_protocol::hook::*; -use bitfun_app_server_protocol::mcp::*; -use bitfun_app_server_protocol::model::*; -use bitfun_app_server_protocol::skill::*; -use bitfun_app_server_protocol::subagent::*; -use bitfun_app_server_protocol::worktree::*; -use bitfun_core::service::remote_connect::account_runtime::{ - AccountRuntime, AccountSyncProgress, AccountSyncStatus, -}; - -use super::{ - AppManagementCapabilities, AppManagementError, AppManagementResult, ACCOUNT_CAPABILITY, - SETTINGS_SYNC_CAPABILITY, WORKTREES_CAPABILITY, -}; - -/// App Server adapter shared by Embedded and local Shared compatibility Hosts. -/// -/// The service delegates to the existing config, registry, MCP, and external -/// source owners. Local-only capabilities must be enabled through the local -/// Host constructor; constructing an App Server does not enable them by default. -pub struct AppManagementService { - config: Arc, - mcp: Option>, - external_source_updates: tokio::sync::broadcast::Sender<( - String, - bitfun_product_domains::external_sources::ExternalSourcePublicSnapshot, - )>, - external_source_subscriptions: Arc>>, - account: Option>, - local_worktrees_enabled: bool, -} - -impl AppManagementService { - pub async fn load() -> Result { - Self::load_inner(None, false).await - } - - pub async fn load_for_local_host(account: Option>) -> Result { - Self::load_inner(account, true).await - } - - async fn load_inner( - account: Option>, - local_worktrees_enabled: bool, - ) -> Result { - let config = bitfun_core::service::config::get_global_config_service() - .await - .context("Failed to load the App Server management configuration owner")?; - let (external_source_updates, _) = tokio::sync::broadcast::channel(64); - Ok(Self { - config, - mcp: bitfun_core::service::mcp::get_global_mcp_service(), - external_source_updates, - external_source_subscriptions: Arc::new(Mutex::new(HashSet::new())), - account, - local_worktrees_enabled, - }) - } - - fn account_runtime(&self, capability: &str) -> AppManagementResult<&Arc> { - self.account - .as_ref() - .ok_or_else(|| AppManagementError::unsupported(format!("{capability} is unavailable"))) - } - - fn require_local_worktrees(&self) -> AppManagementResult<()> { - if self.local_worktrees_enabled { - Ok(()) - } else { - Err(AppManagementError::unsupported(format!( - "{WORKTREES_CAPABILITY} is unavailable" - ))) - } - } - - async fn model_config( - &self, - model_id: &str, - ) -> AppManagementResult { - self.config - .get_ai_models() - .await - .map_err(core_error) - .and_then(|models| { - models - .into_iter() - .find(|model| model.id == model_id) - .ok_or_else(|| { - AppManagementError::not_found(format!( - "AI model '{model_id}' was not found" - )) - }) - }) - } - - pub fn subscribe_external_source_updates( - &self, - ) -> tokio::sync::broadcast::Receiver<( - String, - bitfun_product_domains::external_sources::ExternalSourcePublicSnapshot, - )> { - self.external_source_updates.subscribe() - } - - async fn ensure_external_source_subscription( - &self, - workspace: &Path, - ) -> AppManagementResult<()> { - let workspace_path = workspace.to_string_lossy().to_string(); - { - let mut subscriptions = self - .external_source_subscriptions - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !subscriptions.insert(workspace_path.clone()) { - return Ok(()); - } - } - let mut subscription = - match bitfun_core::external_sources::subscribe_external_source_updates(Some(workspace)) - .await - { - Ok(subscription) => subscription, - Err(error) => { - self.external_source_subscriptions - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(&workspace_path); - return Err(external_source_string_error(error)); - } - }; - let updates = self.external_source_updates.clone(); - let subscriptions = self.external_source_subscriptions.clone(); - tokio::spawn(async move { - loop { - match subscription.recv().await { - Ok(snapshot) => { - let _ = updates.send(( - workspace_path.clone(), - bitfun_product_domains::external_sources::ExternalSourcePublicSnapshot::from(snapshot), - )); - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - subscriptions - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(&workspace_path); - }); - Ok(()) - } -} - -fn external_source_error( - error: bitfun_product_domains::external_sources::ExternalSourceOperationError, -) -> AppManagementError { - use bitfun_product_domains::external_sources::ExternalSourceOperationErrorCode as Code; - let encoded = error.encode(); - match error.code { - Code::InvalidRequest => AppManagementError::invalid_request(encoded), - Code::NotFound => AppManagementError::not_found(encoded), - Code::HostCapabilityUnavailable | Code::Unsupported => { - AppManagementError::unsupported(encoded) - } - _ => AppManagementError::internal(encoded), - } -} - -fn external_source_string_error(error: String) -> AppManagementError { - external_source_error( - bitfun_core::external_sources::sanitize_external_source_operation_error(error), - ) -} - -fn external_source_string_error_with_id(error: String, operation_id: &str) -> AppManagementError { - let mut typed = bitfun_core::external_sources::sanitize_external_source_operation_error(error); - if typed.correlation_id.is_none() { - typed = typed.with_correlation_id(operation_id); - } else if typed.causation_id.is_none() { - typed = typed.with_causation_id(operation_id); - } - external_source_error(typed) -} - -fn validate_external_operation(operation_id: &str) -> AppManagementResult<()> { - bitfun_app_server_protocol::external_source::validate_operation_id(operation_id) - .map_err(AppManagementError::invalid_request) -} - -const MAX_NATIVE_HOOK_COMMAND_CHARS: usize = 200; -const MAX_NATIVE_HOOK_STATUS_CHARS: usize = 200; - -fn bounded_native_hook_text(value: &str, max_chars: usize) -> (String, bool) { - let value = value.trim(); - let truncated = value.chars().count() > max_chars; - let mut summary = value - .chars() - .take(max_chars) - .map(|character| { - if character.is_control() { - ' ' - } else { - character - } - }) - .collect::(); - if truncated { - summary.push_str("..."); - } - (summary, truncated) -} - -fn managed_hook_location(path: &Path) -> String { - let import_id = path - .parent() - .and_then(Path::parent) - .and_then(Path::file_name) - .map(|value| value.to_string_lossy()) - .map(|value| { - value - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { - character - } else { - '_' - } - }) - .collect::() - }) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| "import".to_string()); - format!("/{import_id}/hooks.json") -} - -fn native_hook_location(path: &Path, workspace: &Path, user_hooks_file: Option<&Path>) -> String { - if user_hooks_file.is_some_and(|user| user == path) { - return "/config/hooks.json".to_string(); - } - if let Ok(relative) = path.strip_prefix(workspace) { - let relative = relative.to_string_lossy().replace('\\', "/"); - return if relative.is_empty() { - "".to_string() - } else { - format!("/{relative}") - }; - } - managed_hook_location(path) -} - -fn project_native_hook_overview( - overview: bitfun_core::native_hooks::NativeHookOverview, - workspace: &Path, -) -> NativeHookOverview { - let user_hooks_file = bitfun_core::infrastructure::try_get_path_manager_arc() - .ok() - .map(|manager| manager.user_hooks_file()); - let path_labels = overview - .files - .iter() - .map(|file| { - ( - file.path.clone(), - native_hook_location(&file.path, workspace, user_hooks_file.as_deref()), - ) - }) - .collect::>(); - let sanitize_issue = |issue: String| { - path_labels.iter().fold(issue, |sanitized, (path, label)| { - let native = path.to_string_lossy(); - let sanitized = sanitized.replace(native.as_ref(), label); - sanitized.replace(&native.replace('\\', "/"), label) - }) - }; - - NativeHookOverview { - enabled: overview.enabled, - project_hooks_enabled: overview.project_hooks_enabled, - files: overview - .files - .into_iter() - .zip(path_labels.iter()) - .map(|(file, (_, location))| NativeHookFileSummary { - scope: file.scope.to_string(), - location: location.clone(), - exists: file.exists, - loaded: file.loaded, - }) - .collect(), - rules: overview - .rules - .into_iter() - .map(|rule| NativeHookRuleSummary { - event: rule.event.to_string(), - matcher: rule.matcher, - matcher_is_valid: rule.matcher_is_valid, - scope: rule.scope.to_string(), - handlers: rule - .handlers - .into_iter() - .map(|handler| { - let (command_summary, command_truncated) = bounded_native_hook_text( - &handler.command, - MAX_NATIVE_HOOK_COMMAND_CHARS, - ); - NativeHookHandlerSummary { - command_summary, - command_truncated, - timeout_seconds: handler.timeout_seconds, - status_message: handler.status_message.map(|message| { - bounded_native_hook_text(&message, MAX_NATIVE_HOOK_STATUS_CHARS).0 - }), - } - }) - .collect(), - }) - .collect(), - total_handlers: overview.total_handlers, - issues: overview.issues.into_iter().map(sanitize_issue).collect(), - } -} - -async fn external_source_preferences() -> AppManagementResult { - bitfun_core::external_sources::external_source_conflict_choices() - .await - .map( - |(choices, lineage_current_keys, conflicted_candidate_ids)| { - ExternalSourceConflictPreferences { - choices, - lineage_current_keys, - conflicted_candidate_ids, - } - }, - ) - .map_err(external_source_string_error) -} - -async fn external_source_snapshot_response( - workspace: &Path, - force_refresh: bool, -) -> AppManagementResult { - let surface = bitfun_core::external_sources::get_external_source_control_snapshot( - Some(workspace), - force_refresh, - bitfun_product_domains::external_sources::ExternalSourceHostCapabilities::read_write(), - ) - .await - .map_err(external_source_error)?; - Ok(ExternalSourceSnapshotResponse { - control: surface.control, - snapshot: surface.catalog, - preferences: external_source_preferences().await?, - }) -} - -fn core_error(error: bitfun_core::BitFunError) -> AppManagementError { - AppManagementError::internal(sanitize_management_error(error.to_string())) -} - -fn sanitize_management_error(error: impl AsRef) -> String { - let value = error.as_ref(); - if value.to_ascii_lowercase().contains("api key") - || value.to_ascii_lowercase().contains("authorization") - || value.to_ascii_lowercase().contains("header") - { - "The management owner rejected the request".to_string() - } else { - value.to_string() - } -} - -fn resolve_selector( - ai: &bitfun_core::service::config::AIConfig, - selector: &Option, -) -> Option { - selector - .as_deref() - .and_then(|selector| ai.resolve_model_selection(selector)) -} - -fn resolve_model_selector( - ai: &bitfun_core::service::config::AIConfig, - selector: &str, -) -> Option { - match selector.trim() { - "" | "auto" | "default" => ai.resolve_model_selection("primary"), - selector => ai.resolve_model_selection(selector), - } -} - -fn model_summary(model: &bitfun_core::service::config::AIModelConfig) -> ModelSummary { - let mut custom_header_names = model - .custom_headers - .as_ref() - .map(|headers| headers.keys().cloned().collect::>()) - .unwrap_or_default(); - custom_header_names.sort(); - ModelSummary { - id: model.id.clone(), - name: model.name.clone(), - provider: model.provider.clone(), - model_name: model.model_name.clone(), - base_url: model.base_url.clone(), - enabled: model.enabled, - context_window: model.context_window, - max_tokens: model.max_tokens, - api_key_configured: !model.api_key.is_empty(), - custom_header_names, - custom_request_body_configured: model.custom_request_body.is_some(), - auth_source: Some(match model.auth { - bitfun_core::service::config::AuthConfig::ApiKey => "api_key".to_string(), - bitfun_core::service::config::AuthConfig::Subscription { provider, .. } => { - format!("subscription:{provider:?}").to_ascii_lowercase() - } - }), - } -} - -fn model_edit_projection( - model: &bitfun_core::service::config::AIModelConfig, -) -> ModelEditProjection { - ModelEditProjection { - summary: model_summary(model), - reasoning_preset_options: model - .reasoning - .as_ref() - .map(|reasoning| { - reasoning - .presets - .iter() - .map(|preset| preset.id.clone()) - .collect() - }) - .unwrap_or_default(), - reasoning: model.reasoning.clone(), - inline_think_in_text: model.inline_think_in_text, - skip_ssl_verify: model.skip_ssl_verify, - custom_headers_mode: model - .custom_headers_mode - .clone() - .unwrap_or_else(|| "merge".to_string()), - } -} - -fn secret_update_value( - update: Option, - existing: Option, -) -> AppManagementResult { - Ok(match update.unwrap_or(SecretUpdate::Preserve) { - SecretUpdate::Preserve => Ok(existing.unwrap_or_default()), - SecretUpdate::Replace(value) => Ok(value), - SecretUpdate::Clear => Ok(String::new()), - }?) -} - -fn headers_update( - update: Option, - existing: Option>, -) -> AppManagementResult>> { - match update.unwrap_or(SecretUpdate::Preserve) { - SecretUpdate::Preserve => Ok(existing), - SecretUpdate::Clear => Ok(None), - SecretUpdate::Replace(value) => serde_json::from_str(&value) - .map(Some) - .map_err(|_| AppManagementError::invalid_request("Custom headers must be valid JSON")), - } -} - -fn string_update( - update: Option, - existing: Option, -) -> AppManagementResult> { - match update.unwrap_or(SecretUpdate::Preserve) { - SecretUpdate::Preserve => Ok(existing), - SecretUpdate::Clear => Ok(None), - SecretUpdate::Replace(value) => Ok((!value.is_empty()).then_some(value)), - } -} - -fn model_from_mutation( - mutation: ModelMutation, - existing: Option, -) -> AppManagementResult { - let current = existing.unwrap_or_default(); - let api_key = secret_update_value(mutation.api_key, Some(current.api_key))?; - let custom_headers = headers_update(mutation.custom_headers, current.custom_headers)?; - let custom_request_body = - string_update(mutation.custom_request_body, current.custom_request_body)?; - Ok(bitfun_core::service::config::AIModelConfig { - id: mutation.id, - name: mutation.name, - provider: mutation.provider, - model_name: mutation.model_name, - base_url: mutation.base_url, - request_url: current.request_url, - api_key, - context_window: mutation.context_window, - max_tokens: mutation.max_tokens, - temperature: current.temperature, - top_p: current.top_p, - enabled: mutation.enabled, - category: current.category, - capabilities: current.capabilities, - recommended_for: current.recommended_for, - metadata: current.metadata, - reasoning: mutation.reasoning, - inline_think_in_text: mutation.inline_think_in_text, - custom_headers, - custom_headers_mode: mutation.custom_headers_mode.or(current.custom_headers_mode), - skip_ssl_verify: mutation.skip_ssl_verify, - custom_request_body, - custom_request_body_mode: current.custom_request_body_mode, - auth: current.auth, - }) -} - -fn validate_model_update_identity( - model_id: &str, - mutation: &ModelMutation, -) -> AppManagementResult<()> { - if mutation.id != model_id { - return Err(AppManagementError::invalid_request( - "Model update identity does not match the request target", - )); - } - Ok(()) -} - -fn selector_is_unset(selector: &Option) -> bool { - selector - .as_deref() - .is_none_or(|selector| selector.trim().is_empty()) -} - -fn skill_from_info( - info: bitfun_core::agentic::tools::implementations::skills::SkillInfo, -) -> SkillSummary { - SkillSummary { - key: info.key, - name: info.name, - description: info.description, - level: info.level.as_str().to_string(), - source_slot: Some(info.source_slot), - source_label: Some(info.source_label), - enabled: true, - selected_for_runtime: true, - default_enabled: true, - is_shadowed: info.is_shadowed, - shadowed_by_key: info.shadowed_by_key, - argument_hint: info.argument_hint, - } -} - -fn skill_from_mode_info( - info: bitfun_core::agentic::tools::implementations::skills::ModeSkillInfo, -) -> SkillSummary { - let skill = info.skill; - SkillSummary { - key: skill.key, - name: skill.name, - description: skill.description, - level: skill.level.as_str().to_string(), - source_slot: Some(skill.source_slot), - source_label: Some(skill.source_label), - enabled: info.effective_enabled, - selected_for_runtime: info.selected_for_runtime, - default_enabled: info.default_enabled, - is_shadowed: skill.is_shadowed, - shadowed_by_key: skill.shadowed_by_key, - argument_hint: skill.argument_hint, - } -} - -fn subagent_from_info(info: bitfun_core::agentic::agents::AgentInfo) -> SubagentSummary { - let is_external = - info.subagent_source == Some(bitfun_core::agentic::agents::SubAgentSource::External); - SubagentSummary { - key: info.key, - id: info.id, - name: info.name, - description: info.description, - source: format!( - "{:?}", - info.subagent_source - .unwrap_or(bitfun_core::agentic::agents::SubAgentSource::Builtin) - ) - .to_ascii_lowercase(), - enabled: info.effective_enabled, - is_external, - supports_follow_up: info.supports_follow_up, - } -} - -fn native_mcp_detail(config: &bitfun_core::service::mcp::MCPServerConfig) -> String { - let server_type = format!("{:?}", config.server_type).to_ascii_lowercase(); - let transport = config.resolved_transport().as_str(); - if config.server_type == bitfun_core::service::mcp::MCPServerType::Local { - format!("type: {server_type}; transport: {transport}; command: {}; arguments: {}; environment variables set: {}", - config.command.as_deref().unwrap_or("unknown"), - config.args.len(), - if config.env.is_empty() { "none" } else { "configured" }) - } else { - let origin = config - .url - .as_deref() - .and_then(|value| url::Url::parse(value).ok()) - .and_then(|url| { - let host = url.host_str()?; - Some(match url.port() { - Some(port) => format!("{}://{}:{}", url.scheme(), host, port), - None => format!("{}://{}", url.scheme(), host), - }) - }) - .unwrap_or_else(|| "unknown".to_string()); - format!( - "type: {server_type}; transport: {transport}; remote origin: {}; HTTP headers: {}", - origin, - if config.headers.is_empty() { - "none" - } else { - "configured" - } - ) - } -} - -fn external_mcp_action( - entry: &bitfun_product_domains::external_sources::ExternalMcpCatalogEntry, - snapshot: &bitfun_product_domains::external_sources::ExternalSourceCatalogSnapshot, -) -> McpServerAction { - use bitfun_product_domains::external_sources::ExternalMcpActivationState as State; - match &entry.activation_state { - State::ApprovalRequired | State::Declined | State::ConfigurationChanged => { - McpServerAction::ExternalDecision { - candidate_id: entry.candidate_id.clone(), - decision_key: entry.decision_key.clone(), - approved: true, - expected_mcp_generation: snapshot.mcp_generation, - expected_preference_revision: snapshot.preference_revision, - } - } - State::Starting | State::Active | State::RuntimeUnavailable { .. } => { - McpServerAction::ExternalDecision { - candidate_id: entry.candidate_id.clone(), - decision_key: entry.decision_key.clone(), - approved: false, - expected_mcp_generation: snapshot.mcp_generation, - expected_preference_revision: snapshot.preference_revision, - } - } - State::Conflict | State::Covered { .. } => snapshot - .mcp_conflicts - .iter() - .find(|conflict| { - conflict - .candidates - .iter() - .any(|candidate| candidate.candidate_id == entry.candidate_id) - }) - .map(|conflict| McpServerAction::ConflictChoice { - conflict_key: conflict.conflict_key.clone(), - candidate_id: entry.candidate_id.clone(), - approve_external: true, - expected_mcp_generation: snapshot.mcp_generation, - expected_preference_revision: snapshot.preference_revision, - }) - .unwrap_or_else(|| McpServerAction::ReadOnly { - reason: "Refresh to review the current conflict".to_string(), - }), - State::Unsupported { reason } => McpServerAction::ReadOnly { - reason: format!("Not supported: {reason}"), - }, - State::SourceDisabled => McpServerAction::ReadOnly { - reason: "Enable this server in the source application".to_string(), - }, - State::Removed => McpServerAction::ReadOnly { - reason: "Removed".to_string(), - }, - _ => McpServerAction::ReadOnly { - reason: "This external MCP state is read-only".to_string(), - }, - } -} - -async fn external_mcp_status( - entry: &bitfun_product_domains::external_sources::ExternalMcpCatalogEntry, - manager: &bitfun_core::service::mcp::MCPServerManager, -) -> String { - use bitfun_product_domains::external_sources::ExternalMcpActivationState as State; - match &entry.activation_state { - State::Active => match entry.runtime_id.as_deref() { - Some(id) => match tokio::time::timeout( - std::time::Duration::from_millis(30), - manager.get_server_status(id), - ) - .await - { - Ok(Ok(value)) => format!("{value:?}"), - Ok(Err(_)) => "Unavailable".to_string(), - Err(_) => "Starting".to_string(), - }, - None => "Enabled".to_string(), - }, - State::ApprovalRequired => "Confirmation required".to_string(), - State::Starting => "Starting".to_string(), - State::Declined => "Kept disabled".to_string(), - State::Conflict => "Choice required".to_string(), - State::Covered { .. } => "Not selected".to_string(), - State::SourceDisabled => "Source disabled".to_string(), - State::ConfigurationChanged => "Changed; confirm again".to_string(), - State::Unsupported { .. } => "Not supported".to_string(), - State::RuntimeUnavailable { reason } => format!("Unavailable - {reason}"), - State::Removed => "Removed".to_string(), - _ => "Unavailable".to_string(), - } -} - -fn external_mcp_detail( - entry: &bitfun_product_domains::external_sources::ExternalMcpCatalogEntry, -) -> String { - let definition = &entry.definition; - match definition.transport { - bitfun_product_domains::external_sources::ExternalMcpTransportKind::LocalStdio => format!( - "source MCP configuration; local command: {}; arguments: {}; environment variables set: {}", - definition.command_preview.as_deref().unwrap_or("unknown"), - definition.argument_count, - if definition.environment_keys.is_empty() { "none" } else { "configured" }, - ), - bitfun_product_domains::external_sources::ExternalMcpTransportKind::StreamableHttp => format!( - "source MCP configuration; remote origin: {}; HTTP headers: {}", - definition.remote_url_preview.as_deref().unwrap_or("unknown"), - if definition.header_names.is_empty() { "none" } else { "configured" }, - ), - _ => "unsupported external MCP transport".to_string(), - } -} - -fn mcp_config_from_mutation( - name: &str, - mutation: McpServerMutation, -) -> AppManagementResult { - let (server_type, transport) = match mutation.transport { - McpTransport::Stdio => ( - bitfun_core::service::mcp::MCPServerType::Local, - bitfun_core::service::mcp::MCPServerTransport::Stdio, - ), - McpTransport::Sse => ( - bitfun_core::service::mcp::MCPServerType::Remote, - bitfun_core::service::mcp::MCPServerTransport::Sse, - ), - McpTransport::StreamableHttp => ( - bitfun_core::service::mcp::MCPServerType::Remote, - bitfun_core::service::mcp::MCPServerTransport::StreamableHttp, - ), - }; - let oauth = mutation - .oauth - .map(serde_json::from_value) - .transpose() - .map_err(|_| { - AppManagementError::invalid_request( - "MCP OAuth configuration does not match the supported schema", - ) - })?; - let xaa = mutation - .xaa - .map(serde_json::from_value) - .transpose() - .map_err(|_| { - AppManagementError::invalid_request( - "MCP XAA configuration does not match the supported schema", - ) - })?; - Ok(bitfun_core::service::mcp::MCPServerConfig { - id: name.to_string(), - name: name.to_string(), - server_type, - transport: Some(transport), - command: mutation.command, - args: mutation.args, - env: mutation.env, - working_directory: None, - inherit_parent_environment: None, - headers: mutation.headers, - url: mutation.url, - auto_start: mutation.auto_start, - enabled: mutation.enabled, - location: bitfun_core::service::mcp::ConfigLocation::User, - capabilities: Vec::new(), - settings: HashMap::new(), - oauth, - oauth_enabled: None, - xaa, - timeouts: Default::default(), - }) -} - -fn schedule_mcp_stop(manager: Arc, server_id: String) { - tokio::spawn(async move { - for attempt in 1..=20 { - let result = tokio::time::timeout( - std::time::Duration::from_millis(250), - manager.stop_server(&server_id), - ) - .await; - match result { - Ok(Ok(())) | Ok(Err(bitfun_core::util::errors::BitFunError::NotFound(_))) => return, - Ok(Err(error)) => tracing::debug!( - "Best-effort MCP stop failed: id={} attempt={} error={}", - server_id, - attempt, - error - ), - Err(_) => tracing::debug!( - "Best-effort MCP stop timed out: id={} attempt={}", - server_id, - attempt - ), - } - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - } - tracing::warn!("Best-effort MCP stop exhausted retries: id={}", server_id); - }); -} - -impl AppManagementService { - pub fn capabilities(&self) -> AppManagementCapabilities { - let mut capabilities = AppManagementCapabilities::available(); - if self.mcp.is_none() { - capabilities.mcp = - bitfun_app_server_protocol::app::CapabilityAvailability::Unavailable { - reason: "The App Server Host MCP owner is unavailable".to_string(), - }; - } - if self.account.is_none() { - let reason = "The Host did not provide an account owner".to_string(); - capabilities.account = - bitfun_app_server_protocol::app::CapabilityAvailability::Unavailable { - reason: reason.clone(), - }; - capabilities.settings_sync = - bitfun_app_server_protocol::app::CapabilityAvailability::Unavailable { reason }; - } - if !self.local_worktrees_enabled { - capabilities.worktrees = - bitfun_app_server_protocol::app::CapabilityAvailability::Unavailable { - reason: "The Host did not enable local Worktree management".to_string(), - }; - } - capabilities - } - - pub async fn worktree_repository_status( - &self, - request: WorktreeRepositoryStatusRequest, - ) -> AppManagementResult { - self.require_local_worktrees()?; - super::worktree::repository_status(request).await - } - - pub async fn worktree_bind_session( - &self, - request: WorktreeBindSessionRequest, - ) -> AppManagementResult { - self.require_local_worktrees()?; - super::worktree::bind_session(request).await - } - - pub async fn worktree_release_session( - &self, - request: WorktreeReleaseSessionRequest, - ) -> AppManagementResult { - self.require_local_worktrees()?; - super::worktree::release_session(request).await - } - - pub async fn account_snapshot( - &self, - request: AccountSnapshotRequest, - ) -> AppManagementResult { - let _ = request.workspace_path; - Ok(project_account_snapshot( - self.account_runtime(ACCOUNT_CAPABILITY)?.snapshot().await, - )) - } - - pub async fn account_login( - &self, - request: AccountLoginRequest, - ) -> AppManagementResult { - validate_account_operation_id(&request.operation_id)?; - let result = self - .account_runtime(ACCOUNT_CAPABILITY)? - .login_with_credentials(&request.relay_url, &request.username, &request.password) - .await - .map_err(|error| account_error(error, &request))?; - let status_message = account_login_status_message(&result); - Ok(AccountLoginResponse { - user_id: result.user_id, - relay_url: result.relay_url, - has_cloud_settings: result.has_cloud_settings, - status_message, - }) - } - - pub async fn account_finalize_login( - &self, - request: AccountFinalizeLoginRequest, - ) -> AppManagementResult { - validate_account_operation_id(&request.operation_id)?; - let account = self.account_runtime(ACCOUNT_CAPABILITY)?; - account - .finalize_login_after_sync_choice() - .await - .map_err(internal_account_error)?; - if !account - .start_auto_sync_background( - request.operation_id, - request.choice == AccountSyncChoice::Local, - PathBuf::from(request.workspace_path), - ) - .await - { - return Err(AppManagementError::invalid_request( - "Account settings sync is already in progress", - )); - } - Ok(project_account_snapshot(account.snapshot().await)) - } - - pub async fn account_logout( - &self, - request: AccountLogoutRequest, - ) -> AppManagementResult { - validate_account_operation_id(&request.operation_id)?; - let account = self.account_runtime(ACCOUNT_CAPABILITY)?; - account.logout().await.map_err(internal_account_error)?; - account.mark_sync_cancelled(request.operation_id).await; - Ok(project_account_snapshot(account.snapshot().await)) - } - - pub async fn settings_sync_start( - &self, - request: SettingsSyncStartRequest, - ) -> AppManagementResult { - validate_account_operation_id(&request.operation_id)?; - let account = self.account_runtime(SETTINGS_SYNC_CAPABILITY)?; - if !account.is_logged_in().await { - return Err(AppManagementError::invalid_request( - "Account login must be finalized before settings sync starts", - )); - } - if !account - .start_auto_sync_background( - request.operation_id, - request.is_first_login, - PathBuf::from(request.workspace_path), - ) - .await - { - return Err(AppManagementError::invalid_request( - "Account settings sync is already in progress", - )); - } - Ok(SettingsSyncResponse { - progress: project_sync_progress(account.current_sync_progress().await), - }) - } - - pub async fn settings_sync_snapshot( - &self, - request: SettingsSyncSnapshotRequest, - ) -> AppManagementResult { - let _ = request; - let progress = self - .account_runtime(SETTINGS_SYNC_CAPABILITY)? - .current_sync_progress() - .await; - Ok(SettingsSyncResponse { - progress: project_sync_progress(progress), - }) - } - - pub async fn settings_sync_cancel( - &self, - request: SettingsSyncCancelRequest, - ) -> AppManagementResult { - validate_account_operation_id(&request.operation_id)?; - let progress = self - .account_runtime(SETTINGS_SYNC_CAPABILITY)? - .cancel_sync(request.operation_id) - .await - .map_err(internal_account_error)?; - Ok(SettingsSyncResponse { - progress: project_sync_progress(progress), - }) - } - - pub async fn settings_sync_local_changed( - &self, - request: SettingsSyncLocalChangedRequest, - ) -> AppManagementResult { - validate_account_operation_id(&request.operation_id)?; - let account = self.account_runtime(SETTINGS_SYNC_CAPABILITY)?; - account.notify_local_settings_changed(); - Ok(SettingsSyncResponse { - progress: project_sync_progress(account.current_sync_progress().await), - }) - } - - pub async fn native_hook_overview( - &self, - request: NativeHookOverviewRequest, - ) -> AppManagementResult { - let workspace = Path::new(&request.workspace_path); - let overview = bitfun_core::native_hooks::overview(Some(workspace)).await; - Ok(NativeHookOverviewResponse(project_native_hook_overview( - overview, workspace, - ))) - } - - pub async fn external_hook_snapshot( - &self, - request: ExternalHookSnapshotRequest, - ) -> AppManagementResult { - bitfun_core::external_hook_import::external_hook_import_snapshot( - Some(Path::new(&request.workspace_path)), - request.refresh_updates, - ) - .await - .map(ExternalHookSnapshotResponse) - .map_err(external_source_error) - } - - pub async fn external_hook_plan( - &self, - request: ExternalHookPlanRequest, - ) -> AppManagementResult { - bitfun_core::external_hook_import::plan_external_hook_import( - Some(Path::new(&request.workspace_path)), - request.source, - ) - .await - .map(ExternalHookPlanResponse) - .map_err(external_source_error) - } - - pub async fn external_hook_apply( - &self, - request: ExternalHookApplyRequest, - ) -> AppManagementResult { - validate_external_operation(&request.operation_id)?; - bitfun_core::external_hook_import::apply_external_hook_import( - Some(Path::new(&request.workspace_path)), - request.import_request, - ) - .await - .map(ExternalHookApplyResponse) - .map_err(external_source_error) - } - - pub async fn external_hook_mutate( - &self, - request: ExternalHookMutationRequest, - ) -> AppManagementResult { - validate_external_operation(&request.operation_id)?; - bitfun_core::external_hook_import::mutate_external_hook_import( - Some(Path::new(&request.workspace_path)), - request.mutation, - ) - .await - .map(ExternalHookMutationResponse) - .map_err(external_source_error) - } - - pub async fn external_source_snapshot( - &self, - request: ExternalSourceSnapshotRequest, - ) -> AppManagementResult { - let workspace = Path::new(&request.workspace_path); - self.ensure_external_source_subscription(workspace).await?; - external_source_snapshot_response(workspace, request.force_refresh).await - } - - pub async fn external_source_control( - &self, - request: ExternalSourceControlRequest, - ) -> AppManagementResult { - request - .request - .validate() - .map_err(AppManagementError::invalid_request)?; - let workspace = Path::new(&request.workspace_path); - let surface = bitfun_core::external_sources::apply_external_source_control_action( - Some(workspace), - request.request, - ) - .await - .map_err(external_source_error)?; - Ok(ExternalSourceControlResponse { - surface, - snapshot: external_source_snapshot_response(workspace, false).await?, - }) - } - - pub async fn external_source_review( - &self, - request: ExternalSourceReviewRequest, - ) -> AppManagementResult { - validate_external_operation(&request.operation_id)?; - let workspace = Path::new(&request.workspace_path); - let operation_id = request.operation_id.clone(); - let result = match request.action { - ExternalSourceReviewAction::Refresh => { - bitfun_core::external_sources::external_source_snapshot(Some(workspace), true).await - } - ExternalSourceReviewAction::SetPromptCommandConflictChoice { - conflict_key, - candidate_id, - expected_preference_revision, - } => { - bitfun_core::external_sources::set_external_prompt_command_conflict_choice( - Some(workspace), - &conflict_key, - &candidate_id, - expected_preference_revision, - ) - .await - } - ExternalSourceReviewAction::SetToolTargetDecision { - approval_key, - decision_key, - approved, - expected_preference_revision, - } => { - bitfun_core::external_sources::set_external_tool_target_decision( - Some(workspace), - &approval_key, - &decision_key, - approved, - expected_preference_revision, - ) - .await - } - ExternalSourceReviewAction::SetToolConflictChoice { - conflict_key, - candidate_id, - expected_preference_revision, - } => { - bitfun_core::external_sources::set_external_tool_conflict_choice( - Some(workspace), - &conflict_key, - &candidate_id, - expected_preference_revision, - ) - .await - } - ExternalSourceReviewAction::SetSubagentActivation { - candidate_id, - approved, - expected_subagent_generation, - expected_preference_revision, - decision_key, - } => { - bitfun_core::external_sources::set_external_subagent_activation( - Some(workspace), - &candidate_id, - approved, - expected_subagent_generation, - expected_preference_revision, - &decision_key, - ) - .await - } - ExternalSourceReviewAction::SetSubagentModelBinding { - binding_key, - target, - expected_subagent_generation, - expected_preference_revision, - } => { - bitfun_core::external_sources::set_external_subagent_model_binding( - Some(workspace), - &binding_key, - target, - expected_subagent_generation, - expected_preference_revision, - ) - .await - } - ExternalSourceReviewAction::ChooseSubagentConflict { - conflict_key, - candidate_id, - approve_external, - expected_subagent_generation, - expected_preference_revision, - } => { - bitfun_core::external_sources::choose_external_subagent_conflict( - Some(workspace), - &conflict_key, - &candidate_id, - approve_external, - expected_subagent_generation, - expected_preference_revision, - ) - .await - } - }; - result.map_err(|error| external_source_string_error_with_id(error, &operation_id))?; - Ok(ExternalSourceReviewResponse( - external_source_snapshot_response(workspace, false).await?, - )) - } - - pub async fn set_native_command_choice( - &self, - request: SetNativeCommandChoiceRequest, - ) -> AppManagementResult { - validate_external_operation(&request.operation_id)?; - let operation_id = request.operation_id.clone(); - let conflicts = bitfun_core::external_sources::set_native_prompt_command_conflict_choice( - Some(Path::new(&request.workspace_path)), - request.native_commands, - &request.selected_candidate_id, - request.expected_preference_revision, - ) - .await - .map_err(|error| external_source_string_error_with_id(error, &operation_id))?; - Ok(SetNativeCommandChoiceResponse { - conflicts, - preferences: external_source_preferences().await?, - }) - } - - pub async fn expand_external_command( - &self, - request: ExpandExternalCommandRequest, - ) -> AppManagementResult { - validate_external_operation(&request.operation_id)?; - let operation_id = request.operation_id.clone(); - bitfun_core::external_sources::expand_external_prompt_command( - Some(Path::new(&request.workspace_path)), - &request.command_name, - &request.arguments, - request.native_commands, - request.candidate_id.as_deref(), - request.content_version.as_deref(), - request.native_conflict_key.as_deref(), - request.expected_preference_revision, - request.shell_review_decision.as_ref(), - ) - .await - .map(ExpandExternalCommandResponse) - .map_err(|error| external_source_string_error_with_id(error, &operation_id)) - } - - pub async fn list_agent_modes( - &self, - request: ListAgentModesRequest, - ) -> AppManagementResult { - let workspace = request.workspace_path.map(PathBuf::from); - if request.include_external { - if let Err(error) = - bitfun_core::external_sources::ensure_external_source_workspace_snapshot( - workspace.as_deref(), - ) - .await - { - tracing::warn!("Failed to initialize external agent sources: {error}"); - } - } - let modes = bitfun_core::agentic::agents::get_agent_registry() - .get_modes_info_for_workspace(workspace.as_deref(), request.include_external) - .await - .into_iter() - .map(|mode| AgentModeSummary { - id: mode.id, - description: mode.description, - model_id: mode.model, - is_external: mode.source == bitfun_core::agentic::agents::AgentSource::External, - }) - .collect(); - Ok(ListAgentModesResponse { modes }) - } - - pub async fn list_models( - &self, - _request: ListModelsRequest, - ) -> AppManagementResult { - let models = self.config.get_ai_models().await.map_err(core_error)?; - let config: bitfun_core::service::config::GlobalConfig = - self.config.get_config(None).await.map_err(core_error)?; - Ok(ListModelsResponse { - models: models.iter().map(model_summary).collect(), - primary_model_id: resolve_selector(&config.ai, &config.ai.default_models.primary), - fast_model_id: resolve_selector(&config.ai, &config.ai.default_models.fast), - mode_default_model_id: resolve_model_selector( - &config.ai, - &config.ai.agent_model_defaults.mode, - ), - }) - } - - pub async fn get_model( - &self, - request: GetModelRequest, - ) -> AppManagementResult { - Ok(GetModelResponse { - model: model_edit_projection(&self.model_config(&request.model_id).await?), - }) - } - - pub async fn tui_model_catalog( - &self, - _request: TuiModelCatalogRequest, - ) -> AppManagementResult { - let catalog = bitfun_core::get_ai_model_catalog() - .await - .map_err(AppManagementError::internal)?; - let reasoning_presets_by_model = catalog - .models - .into_iter() - .filter_map(|model| { - model.reasoning.map(|reasoning| { - ( - model.id, - reasoning - .presets - .into_iter() - .map(|preset| preset.id) - .collect(), - ) - }) - }) - .collect(); - Ok(TuiModelCatalogResponse { - provider_catalog: catalog.provider_catalog, - reasoning_presets_by_model, - }) - } - - pub async fn project_reasoning_catalog( - &self, - request: ProjectReasoningCatalogRequest, - ) -> AppManagementResult { - Ok(ProjectReasoningCatalogResponse { - projection: bitfun_core::project_ai_model_reasoning_catalog(request.0).await, - }) - } - - pub async fn add_model( - &self, - request: AddModelRequest, - ) -> AppManagementResult { - let model = model_from_mutation(request.model, None)?; - let model_id = model.id.clone(); - self.config.add_ai_model(model).await.map_err(core_error)?; - if request.make_primary_if_empty { - let config: bitfun_core::service::config::GlobalConfig = - self.config.get_config(None).await.map_err(core_error)?; - if selector_is_unset(&config.ai.default_models.primary) { - self.config - .set_config("ai.default_models.primary", &Some(model_id)) - .await - .map_err(core_error)?; - } - } - Ok(AddModelResponse {}) - } - - pub async fn update_model( - &self, - request: UpdateModelRequest, - ) -> AppManagementResult { - validate_model_update_identity(&request.model_id, &request.model)?; - let existing = self.model_config(&request.model_id).await?; - let model = model_from_mutation(request.model, Some(existing))?; - self.config - .update_ai_model(&request.model_id, model) - .await - .map_err(core_error)?; - Ok(UpdateModelResponse {}) - } - - pub async fn delete_model( - &self, - request: DeleteModelRequest, - ) -> AppManagementResult { - self.config - .delete_ai_model(&request.model_id) - .await - .map_err(core_error)?; - Ok(DeleteModelResponse {}) - } - - pub async fn set_model_default( - &self, - request: SetModelDefaultRequest, - ) -> AppManagementResult { - match request.slot { - ModelDefaultSlot::Primary => self - .config - .set_config("ai.default_models.primary", &request.model_id) - .await - .map_err(core_error)?, - ModelDefaultSlot::Mode => self - .config - .set_config( - "ai.agent_model_defaults.mode", - request.model_id.as_deref().unwrap_or("auto"), - ) - .await - .map_err(core_error)?, - } - Ok(SetModelDefaultResponse {}) - } - - pub async fn list_skills( - &self, - request: ListSkillsRequest, - ) -> AppManagementResult { - let workspace = PathBuf::from(&request.workspace_path); - let registry = bitfun_core::agentic::tools::implementations::skills::get_skill_registry(); - let skills = if request.manageable { - registry - .get_mode_skill_infos_for_workspace(Some(&workspace), &request.mode_id) - .await - .into_iter() - .map(skill_from_mode_info) - .collect() - } else { - registry - .get_user_invocable_skills_for_workspace(Some(&workspace), Some(&request.mode_id)) - .await - .into_iter() - .map(skill_from_info) - .collect() - }; - Ok(ListSkillsResponse { skills }) - } - - pub async fn set_skill_enabled( - &self, - request: SetSkillEnabledRequest, - ) -> AppManagementResult { - let workspace = PathBuf::from(&request.workspace_path); - match request.level.as_str() { - "user" => { - let _ = bitfun_core::agentic::tools::implementations::skills::mode_overrides::set_user_mode_skill_state( - &request.mode_id, - &request.skill_key, - request.enabled, - request.default_enabled, - ) - .await - .map_err(core_error)?; - } - "project" => { - let mut document = bitfun_core::agentic::tools::implementations::skills::mode_overrides::load_project_mode_skills_document_local(&workspace) - .await - .map_err(core_error)?; - bitfun_core::agentic::tools::implementations::skills::mode_overrides::set_mode_skill_disabled_in_document( - &mut document, - &request.mode_id, - &request.skill_key, - !request.enabled, - ) - .map_err(core_error)?; - bitfun_core::agentic::tools::implementations::skills::mode_overrides::save_project_mode_skills_document_local( - &workspace, - &document, - ) - .await - .map_err(core_error)?; - } - level => { - return Err(AppManagementError::invalid_request(format!( - "Unsupported skill level '{level}'" - ))) - } - } - Ok(SetSkillEnabledResponse {}) - } - - pub async fn list_subagents( - &self, - request: ListSubagentsRequest, - ) -> AppManagementResult { - let workspace = PathBuf::from(&request.workspace_path); - let scope = if request.management { - bitfun_core::agentic::agents::SubagentListScope::RegistryManagement - } else { - bitfun_core::agentic::agents::SubagentListScope::TaskVisible - }; - let values = bitfun_core::agentic::agents::get_agent_registry() - .get_subagents_for_query(&bitfun_core::agentic::agents::SubagentQueryContext { - parent_agent_type: Some(&request.parent_mode_id), - workspace_root: Some(&workspace), - list_scope: scope, - include_disabled: request.management, - external_sources_supported: true, - }) - .await; - let has_external = values.iter().any(|info| { - info.subagent_source == Some(bitfun_core::agentic::agents::SubAgentSource::External) - }); - Ok(ListSubagentsResponse { - subagents: values - .into_iter() - .filter(|info| { - !request.management - || info.subagent_source - != Some(bitfun_core::agentic::agents::SubAgentSource::External) - }) - .map(subagent_from_info) - .collect(), - has_external, - }) - } - - pub async fn set_subagent_enabled( - &self, - request: SetSubagentEnabledRequest, - ) -> AppManagementResult { - let workspace = PathBuf::from(&request.workspace_path); - bitfun_core::agentic::agents::get_agent_registry() - .update_subagent_override( - &request.parent_mode_id, - &request.subagent_id, - request.enabled, - Some(&workspace), - ) - .await - .map_err(core_error)?; - Ok(SetSubagentEnabledResponse {}) - } - - pub async fn list_mcp_servers( - &self, - request: ListMcpServersRequest, - ) -> AppManagementResult { - let mcp = self.mcp.as_ref().ok_or_else(|| { - AppManagementError::unsupported("The App Server Host MCP owner is unavailable") - })?; - let workspace = PathBuf::from(request.workspace_path); - let external = - bitfun_core::external_sources::external_source_snapshot(Some(&workspace), false) - .await - .map_err(|error| AppManagementError::internal(sanitize_management_error(error)))?; - let tool_registry = bitfun_core::agentic::tools::registry::get_global_tool_registry(); - let tools = tool_registry.read().await.get_all_tools(); - let configs = mcp - .config_service() - .load_all_configs() - .await - .map_err(core_error)?; - let manager = mcp.server_manager(); - let mut servers = Vec::new(); - for config in configs { - let status = if !config.enabled { - "Stopped".to_string() - } else { - match tokio::time::timeout( - std::time::Duration::from_millis(30), - manager.get_server_status(&config.id), - ) - .await - { - Ok(Ok(value)) => format!("{value:?}"), - _ => "Starting".to_string(), - } - }; - let prefix = format!("mcp_{}_", config.id); - let tool_count = tools - .iter() - .filter(|tool| tool.name().starts_with(&prefix)) - .count(); - let native_id = bitfun_core::external_sources::native_mcp_candidate_id(&config.id); - let conflict = external.mcp_conflicts.iter().find(|conflict| { - conflict - .candidates - .iter() - .any(|candidate| candidate.candidate_id == native_id) - }); - let action = match conflict { - Some(conflict) - if conflict - .candidates - .iter() - .find(|candidate| candidate.candidate_id == native_id) - .is_some_and(|candidate| !candidate.available) => - { - let reason = conflict - .candidates - .iter() - .find(|candidate| candidate.candidate_id == native_id) - .and_then(|candidate| candidate.unavailable_reason.clone()) - .unwrap_or_else(|| { - "Enable this BitFun server in its MCP configuration".to_string() - }); - McpServerAction::ReadOnly { reason } - } - Some(conflict) if conflict.selected_candidate_id.as_deref() != Some(&native_id) => { - McpServerAction::ConflictChoice { - conflict_key: conflict.conflict_key.clone(), - candidate_id: native_id, - approve_external: false, - expected_mcp_generation: external.mcp_generation, - expected_preference_revision: external.preference_revision, - } - } - _ => McpServerAction::NativeToggle, - }; - servers.push(McpServerSummary { - id: config.id.clone(), - name: config.name.clone(), - server_type: format!("{:?}", config.server_type).to_lowercase(), - status, - tool_count, - source_label: "BitFun".to_string(), - external: false, - detail: native_mcp_detail(&config), - action, - }); - } - for entry in &external.mcp_servers { - let source_label = external - .sources - .iter() - .find(|source| source.record.key == entry.definition.id.source) - .map(|source| source.record.display_name.clone()) - .unwrap_or_else(|| "External AI app".to_string()); - let action = external_mcp_action(&entry, &external); - let status = external_mcp_status(&entry, manager.as_ref()).await; - let tool_count = entry.runtime_id.as_deref().map_or(0, |runtime_id| { - let prefix = format!("mcp_{runtime_id}_"); - tools - .iter() - .filter(|tool| tool.name().starts_with(&prefix)) - .count() - }); - servers.push(McpServerSummary { - id: entry.candidate_id.clone(), - name: entry.definition.name.clone(), - server_type: "external".to_string(), - status, - tool_count, - source_label, - external: true, - detail: external_mcp_detail(&entry), - action, - }); - } - if servers.is_empty() && external.discovery_pending { - servers.push(McpServerSummary { - id: "external-mcp-discovery-pending".to_string(), - name: "External MCP servers".to_string(), - server_type: "external".to_string(), - status: "Checking".to_string(), - tool_count: 0, - source_label: "External AI applications".to_string(), - external: true, - detail: "BitFun is still checking compatible MCP settings".to_string(), - action: McpServerAction::ReadOnly { - reason: "Still checking; this list updates automatically".to_string(), - }, - }); - } - let config_path = bitfun_core::infrastructure::try_get_path_manager_arc() - .ok() - .map(|manager| manager.app_config_file().display().to_string()); - Ok(ListMcpServersResponse { - servers, - config_path, - }) - } - - pub async fn toggle_mcp_server( - &self, - request: ToggleMcpServerRequest, - ) -> AppManagementResult { - let mcp = self.mcp.as_ref().ok_or_else(|| { - AppManagementError::unsupported("The App Server Host MCP owner is unavailable") - })?; - let manager = mcp.server_manager(); - match manager.get_server_status(&request.server_id).await { - Ok(bitfun_core::service::mcp::MCPServerStatus::Connected) - | Ok(bitfun_core::service::mcp::MCPServerStatus::Healthy) => { - manager.stop_server(&request.server_id).await - } - _ => manager.start_server(&request.server_id).await, - } - .map_err(core_error)?; - Ok(ToggleMcpServerResponse {}) - } - - pub async fn add_mcp_server( - &self, - request: AddMcpServerRequest, - ) -> AppManagementResult { - let mcp = self.mcp.as_ref().ok_or_else(|| { - AppManagementError::unsupported("The App Server Host MCP owner is unavailable") - })?; - let config = mcp_config_from_mutation(&request.name, request.config)?; - mcp.server_manager() - .add_server(config) - .await - .map_err(core_error)?; - Ok(AddMcpServerResponse {}) - } - - pub async fn delete_mcp_server( - &self, - request: DeleteMcpServerRequest, - ) -> AppManagementResult { - let mcp = self.mcp.as_ref().ok_or_else(|| { - AppManagementError::unsupported("The App Server Host MCP owner is unavailable") - })?; - mcp.config_service() - .delete_server_config(&request.server_id) - .await - .map_err(core_error)?; - schedule_mcp_stop(mcp.server_manager(), request.server_id); - Ok(DeleteMcpServerResponse {}) - } - - pub async fn external_mcp_decision( - &self, - request: ExternalMcpDecisionRequest, - ) -> AppManagementResult { - bitfun_core::external_sources::set_external_mcp_server_decision( - Some(Path::new(&request.workspace_path)), - &request.candidate_id, - &request.decision_key, - request.approved, - request.expected_mcp_generation, - request.expected_preference_revision, - ) - .await - .map_err(|error| AppManagementError::internal(sanitize_management_error(error)))?; - Ok(ExternalMcpDecisionResponse {}) - } - - pub async fn mcp_conflict_choice( - &self, - request: McpConflictChoiceRequest, - ) -> AppManagementResult { - bitfun_core::external_sources::choose_external_mcp_conflict( - Some(Path::new(&request.workspace_path)), - &request.conflict_key, - &request.candidate_id, - request.approve_external, - request.expected_mcp_generation, - request.expected_preference_revision, - ) - .await - .map_err(|error| AppManagementError::internal(sanitize_management_error(error)))?; - Ok(McpConflictChoiceResponse {}) - } -} - -fn project_account_snapshot( - snapshot: bitfun_core::service::remote_connect::account_runtime::AccountSnapshot, -) -> AccountSnapshotResponse { - AccountSnapshotResponse { - logged_in: snapshot.logged_in, - pending_sync_choice: snapshot.pending_sync_choice, - info: snapshot.info.map(|info| AccountInfo { - user_id: info.user_id, - relay_url: info.relay_url, - device_id: info.device_id, - device_name: info.device_name, - }), - devices: snapshot - .devices - .into_iter() - .map(|device| AccountDevice { - device_id: device.device_id, - device_name: device.device_name, - online: device.online, - }) - .collect(), - sync: project_sync_progress(snapshot.sync), - } -} - -fn project_sync_progress(progress: AccountSyncProgress) -> SettingsSyncProgress { - SettingsSyncProgress { - operation_id: progress.operation_id, - status: match progress.status { - AccountSyncStatus::Idle => SettingsSyncStatus::Idle, - AccountSyncStatus::Syncing => SettingsSyncStatus::Syncing, - AccountSyncStatus::Done => SettingsSyncStatus::Done, - AccountSyncStatus::Failed => SettingsSyncStatus::Failed, - AccountSyncStatus::Cancelled => SettingsSyncStatus::Cancelled, - }, - phase: progress.phase, - percent: progress.percent, - current: progress.current, - total: progress.total, - detail: progress.detail, - error: progress.error, - settings_synced: progress.settings_synced, - sessions_exported: progress.sessions_exported, - } -} - -fn account_login_status_message( - result: &bitfun_core::service::remote_connect::account_runtime::AccountLoginResult, -) -> String { - if result.has_cloud_settings { - return format!( - "Authenticated as user {} on {}. Choose cloud or local settings to finish login.", - result.user_id, result.relay_url - ); - } - if result.routing_connected { - format!( - "Logged in as user {} on {}. Device routing connected.", - result.user_id, result.relay_url - ) - } else if let Some(error) = &result.routing_error { - format!( - "Logged in as user {} on {}. Device routing failed: {}", - result.user_id, - result.relay_url, - bounded_error(error.clone()) - ) - } else { - format!( - "Logged in as user {} on {}.", - result.user_id, result.relay_url - ) - } -} - -fn validate_account_operation_id(operation_id: &str) -> AppManagementResult<()> { - let valid = !operation_id.trim().is_empty() - && operation_id.len() <= 128 - && operation_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')); - valid - .then_some(()) - .ok_or_else(|| AppManagementError::invalid_request("Account operation ID is invalid")) -} - -fn account_error(error: anyhow::Error, request: &AccountLoginRequest) -> AppManagementError { - let mut message = error.to_string(); - for secret in [&request.relay_url, &request.username, &request.password] { - if !secret.is_empty() { - message = message.replace(secret, ""); - } - } - AppManagementError::internal(bounded_error(message)) -} - -fn internal_account_error(error: anyhow::Error) -> AppManagementError { - AppManagementError::internal(bounded_error(error.to_string())) -} - -fn bounded_error(message: String) -> String { - message - .chars() - .filter(|character| !character.is_control()) - .take(500) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::management::AppManagementErrorKind; - - fn native_overview_with_sensitive_paths() -> bitfun_core::native_hooks::NativeHookOverview { - bitfun_core::native_hooks::NativeHookOverview { - enabled: true, - project_hooks_enabled: true, - files: vec![ - bitfun_core::native_hooks::NativeHookFileView { - scope: "user", - path: PathBuf::from("C:/Users/private/AppData/Roaming/BitFun/config/hooks.json"), - exists: true, - loaded: true, - }, - bitfun_core::native_hooks::NativeHookFileView { - scope: "project", - path: PathBuf::from("D:/secret/project/.bitfun/config/hooks.json"), - exists: true, - loaded: true, - }, - bitfun_core::native_hooks::NativeHookFileView { - scope: "user", - path: PathBuf::from( - "C:/Users/private/AppData/Roaming/BitFun/runtime/hook-imports/bundles/import-one/version/hooks.json", - ), - exists: true, - loaded: true, - }, - ], - rules: vec![bitfun_core::native_hooks::NativeHookRuleView { - event: "PreToolUse", - matcher: "Bash".to_string(), - matcher_is_valid: true, - scope: "project", - source: "D:/secret/project/.bitfun/config/hooks.json".to_string(), - handlers: vec![bitfun_core::native_hooks::NativeHookHandlerView { - command: format!("secret-token {}", "x".repeat(240)), - timeout_seconds: 5, - status_message: Some("Checking".to_string()), - }], - }], - total_handlers: 1, - issues: vec![ - "Failed to read hook configuration: path=D:/secret/project/.bitfun/config/hooks.json" - .to_string(), - ], - } - } - - fn mutation() -> ModelMutation { - ModelMutation { - id: "model-1".to_string(), - name: "Model".to_string(), - provider: "openai".to_string(), - model_name: "gpt-test".to_string(), - base_url: "https://example.com/v1".to_string(), - api_key: Some(SecretUpdate::Preserve), - custom_headers: Some(SecretUpdate::Preserve), - custom_request_body: Some(SecretUpdate::Preserve), - context_window: Some(128_000), - max_tokens: Some(8_192), - enabled: true, - reasoning: None, - inline_think_in_text: true, - skip_ssl_verify: false, - custom_headers_mode: Some("merge".to_string()), - } - } - - #[test] - fn model_mutation_preserves_and_replaces_write_only_values() { - let existing = bitfun_core::service::config::AIModelConfig { - api_key: "existing-key".to_string(), - custom_headers: Some(HashMap::from([( - "Authorization".to_string(), - "existing-header".to_string(), - )])), - custom_request_body: Some("existing-body".to_string()), - ..Default::default() - }; - - let preserved = model_from_mutation(mutation(), Some(existing.clone())) - .expect("preserve existing model secrets"); - assert_eq!(preserved.api_key, "existing-key"); - assert_eq!(preserved.custom_headers, existing.custom_headers); - assert_eq!( - preserved.custom_request_body.as_deref(), - Some("existing-body") - ); - - let mut replacement = mutation(); - replacement.api_key = Some(SecretUpdate::Replace("new-key".to_string())); - replacement.custom_headers = Some(SecretUpdate::Clear); - replacement.custom_request_body = Some(SecretUpdate::Replace("new-body".to_string())); - let replaced = - model_from_mutation(replacement, Some(existing)).expect("replace model secrets"); - assert_eq!(replaced.api_key, "new-key"); - assert!(replaced.custom_headers.is_none()); - assert_eq!(replaced.custom_request_body.as_deref(), Some("new-body")); - } - - #[test] - fn native_hook_projection_replaces_paths_and_bounds_command_summaries() { - let overview = project_native_hook_overview( - native_overview_with_sensitive_paths(), - Path::new("D:/secret/project"), - ); - - assert_eq!( - overview.files[1].location, - "/.bitfun/config/hooks.json" - ); - assert!(overview.files[2].location.starts_with("/")); - assert!(overview.rules[0].handlers[0].command_truncated); - assert_eq!( - overview.rules[0].handlers[0] - .command_summary - .chars() - .count(), - MAX_NATIVE_HOOK_COMMAND_CHARS + 3 - ); - let debug = format!("{:?}", NativeHookOverviewResponse(overview.clone())); - for secret in ["D:/secret/project", "C:/Users/private", "secret-token"] { - assert!(!debug.contains(secret), "native Hook Debug leaked {secret}"); - } - assert!(overview.issues[0].contains("/.bitfun/config/hooks.json")); - assert!(!overview.issues[0].contains("D:/secret/project")); - } - - #[test] - fn model_update_rejects_a_mismatched_payload_identity() { - let error = validate_model_update_identity( - "model-1", - &ModelMutation { - id: "model-2".to_string(), - ..mutation() - }, - ) - .expect_err("mismatched model identity should be rejected"); - - assert_eq!(error.kind, AppManagementErrorKind::InvalidRequest); - assert!(!error.message.contains("model-1")); - assert!(!error.message.contains("model-2")); - } - - #[test] - fn blank_primary_selector_is_treated_as_unset() { - assert!(selector_is_unset(&None)); - assert!(selector_is_unset(&Some(" ".to_string()))); - assert!(!selector_is_unset(&Some("primary-model".to_string()))); - } - - #[test] - fn model_summary_exposes_only_sorted_header_names() { - let model = bitfun_core::service::config::AIModelConfig { - api_key: "secret-key".to_string(), - custom_headers: Some(HashMap::from([ - ("Z-Header".to_string(), "secret-z".to_string()), - ("A-Header".to_string(), "secret-a".to_string()), - ])), - ..Default::default() - }; - - let summary = model_summary(&model); - assert!(summary.api_key_configured); - assert_eq!(summary.custom_header_names, ["A-Header", "Z-Header"]); - let debug = format!("{summary:?}"); - assert!(!debug.contains("secret-key")); - assert!(!debug.contains("secret-z")); - assert!(!debug.contains("secret-a")); - } - - #[test] - fn mcp_mutation_rejects_invalid_auth_shapes_without_echoing_values() { - let error = mcp_config_from_mutation( - "server", - McpServerMutation { - transport: McpTransport::StreamableHttp, - command: None, - args: Vec::new(), - env: HashMap::new(), - headers: HashMap::new(), - url: Some("https://example.com".to_string()), - auto_start: true, - enabled: true, - oauth: Some(serde_json::Value::String("secret-oauth".to_string())), - xaa: None, - }, - ) - .expect_err("invalid OAuth shape should be rejected"); - - assert_eq!(error.kind, AppManagementErrorKind::InvalidRequest); - assert!(!error.message.contains("secret-oauth")); - } - - #[test] - fn native_mcp_detail_omits_remote_credentials_path_and_query() { - let config = bitfun_core::service::mcp::MCPServerConfig { - id: "remote".to_string(), - name: "Remote".to_string(), - server_type: bitfun_core::service::mcp::MCPServerType::Remote, - transport: Some(bitfun_core::service::mcp::MCPServerTransport::StreamableHttp), - command: None, - args: Vec::new(), - env: HashMap::new(), - working_directory: None, - inherit_parent_environment: None, - headers: HashMap::new(), - url: Some( - "https://user:password@example.com:8443/private?token=secret#fragment".to_string(), - ), - auto_start: true, - enabled: true, - location: bitfun_core::service::mcp::ConfigLocation::User, - capabilities: Vec::new(), - settings: HashMap::new(), - oauth: None, - oauth_enabled: None, - xaa: None, - timeouts: Default::default(), - }; - - let detail = native_mcp_detail(&config); - assert!(detail.contains("https://example.com:8443")); - for secret in [ - "user", "password", "/private", "token", "secret", "fragment", - ] { - assert!(!detail.contains(secret), "detail leaked {secret}"); - } - } -} diff --git a/src/crates/interfaces/app-server/src/management/worktree.rs b/src/crates/interfaces/app-server/src/management/worktree.rs deleted file mode 100644 index 3c86d77af3..0000000000 --- a/src/crates/interfaces/app-server/src/management/worktree.rs +++ /dev/null @@ -1,127 +0,0 @@ -use bitfun_app_server_protocol::worktree::*; -use bitfun_core::service::git::GitService; -use bitfun_core::service::worktree::{WorktreeService, WorktreeSessionBindingRequest}; -use bitfun_runtime_ports::{AgentSessionWorkspaceBinding, WorktreeError, WorktreeErrorCode}; - -use super::{AppManagementError, AppManagementResult}; - -pub(crate) async fn repository_status( - request: WorktreeRepositoryStatusRequest, -) -> AppManagementResult { - if request.is_remote() { - return Err(worktree_error(WorktreeOperationError { - code: WorktreeErrorCode::RemoteUnsupported, - message: "Repository status is not supported for remote workspaces".to_string(), - recovery_path: None, - operation_id: None, - })); - } - - let repository = match GitService::resolve_worktree_repository(&request.workspace_path).await { - Ok(repository) => GitService::get_repository_basic(repository.query_path).await, - Err(error) => Err(error), - }; - match repository { - Ok(repository) => Ok(WorktreeRepositoryStatusResponse { - is_repository: true, - current_branch: Some(repository.current_branch), - }), - Err(_) => Ok(WorktreeRepositoryStatusResponse { - is_repository: false, - current_branch: None, - }), - } -} - -pub(crate) async fn bind_session( - request: WorktreeBindSessionRequest, -) -> AppManagementResult { - transition( - request.is_remote(), - request.operation_id, - request.session_id, - request.project_workspace_path, - true, - ) - .await -} - -pub(crate) async fn release_session( - request: WorktreeReleaseSessionRequest, -) -> AppManagementResult { - transition( - request.is_remote(), - request.operation_id, - request.session_id, - request.project_workspace_path, - false, - ) - .await -} - -async fn transition( - remote: bool, - operation_id: String, - session_id: String, - project_workspace_path: Option, - enabled: bool, -) -> AppManagementResult { - validate_operation_id(&operation_id)?; - if remote { - return Err(worktree_error(WorktreeOperationError { - code: WorktreeErrorCode::RemoteUnsupported, - message: "Managed worktrees are not supported for remote workspaces".to_string(), - recovery_path: None, - operation_id: Some(operation_id), - })); - } - - let result = WorktreeService::bind_session(WorktreeSessionBindingRequest { - request_id: operation_id.clone(), - session_id, - project_workspace_path, - enabled, - }) - .await - .map_err(|error| worktree_error(project_error(error, Some(operation_id.clone()))))?; - let execution_target = result.execution_target.clone(); - Ok(WorktreeBindingResponse { - workspace_binding: AgentSessionWorkspaceBinding { - workspace_id: result.workspace_id, - workspace_path: result.workspace_path, - project_workspace_path: Some(result.project_workspace_path), - execution_target: Some(execution_target), - remote_connection_id: None, - remote_ssh_host: None, - }, - retained_worktree_path: result.retained_worktree_path, - }) -} - -fn validate_operation_id(operation_id: &str) -> AppManagementResult<()> { - if !operation_id.trim().is_empty() - && operation_id.len() <= 160 - && operation_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) - { - Ok(()) - } else { - Err(AppManagementError::invalid_request( - "Worktree operation ID is invalid", - )) - } -} - -fn project_error(error: WorktreeError, operation_id: Option) -> WorktreeOperationError { - WorktreeOperationError { - code: error.code, - message: error.message, - recovery_path: error.recovery_path, - operation_id, - } -} - -fn worktree_error(error: WorktreeOperationError) -> AppManagementError { - AppManagementError::internal(error.encode()) -} diff --git a/src/crates/interfaces/app-server/src/server.rs b/src/crates/interfaces/app-server/src/server.rs index e83c400110..fa592d169b 100644 --- a/src/crates/interfaces/app-server/src/server.rs +++ b/src/crates/interfaces/app-server/src/server.rs @@ -12,18 +12,93 @@ mod wire; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use agent_client_protocol::{ConnectTo, ConnectionTo, Result}; +use agent_client_protocol::{BoxFuture, Channel, ConnectTo, ConnectionTo, Result}; +use bitfun_app_server_protocol::app::TransportLimits; use crate::agent::BitfunAppRuntime; -use crate::management::AppManagementService; use crate::role::{AppClient, AppServer}; static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1); +/// Exposes transport closure to the connection foreground. +/// +/// ACP 0.12 waits for every protocol actor when one channel direction closes, +/// while the connection foreground itself keeps the outgoing actor alive. The +/// small bridge below observes either transport direction closing so the +/// foreground can release its `ConnectionTo` and let ACP finish normally. +struct ConnectionAwareTransport { + transport: T, + closed: tokio::sync::watch::Sender, +} + +impl ConnectionAwareTransport { + fn new(transport: T) -> (Self, tokio::sync::watch::Receiver) { + let (closed, receiver) = tokio::sync::watch::channel(false); + (Self { transport, closed }, receiver) + } +} + +impl ConnectTo for ConnectionAwareTransport +where + T: ConnectTo, +{ + async fn connect_to(self, client: impl ConnectTo) -> Result<()> { + let Self { transport, closed } = self; + let result = transport.connect_to(client).await; + closed.send_replace(true); + result + } + + fn into_channel_and_future(self) -> (Channel, BoxFuture<'static, Result<()>>) { + let Self { transport, closed } = self; + let (transport_channel, transport_future) = transport.into_channel_and_future(); + let (protocol_channel, bridge_channel) = Channel::duplex(); + + let future = Box::pin(async move { + let Channel { + rx: transport_incoming, + tx: transport_outgoing, + } = transport_channel; + let Channel { + rx: protocol_outgoing, + tx: protocol_incoming, + } = bridge_channel; + + let incoming = Channel { + rx: transport_incoming, + tx: protocol_incoming, + } + .copy(); + let outgoing = Channel { + rx: protocol_outgoing, + tx: transport_outgoing, + } + .copy(); + let transport_lifecycle = async move { + match transport_future.await { + Ok(()) => std::future::pending::>().await, + Err(error) => Err(error), + } + }; + + let result = tokio::select! { + result = incoming => result, + result = outgoing => result, + result = transport_lifecycle => result, + }; + closed.send_replace(true); + result + }); + + (protocol_channel, future) + } +} + pub(super) struct ConnectionEventState { id: String, agent_sequence: AtomicU64, permission_sequence: AtomicU64, + permission_order: std::sync::Mutex<()>, config_sequence: AtomicU64, external_source_sequence: AtomicU64, } @@ -37,6 +112,7 @@ impl ConnectionEventState { ), agent_sequence: AtomicU64::new(0), permission_sequence: AtomicU64::new(0), + permission_order: std::sync::Mutex::new(()), config_sequence: AtomicU64::new(0), external_source_sequence: AtomicU64::new(0), } @@ -79,25 +155,122 @@ impl ConnectionEventState { sequence: sequence.fetch_add(1, Ordering::AcqRel) + 1, } } + + pub(super) fn capture_permission_snapshot( + &self, + capture: impl FnOnce() -> T, + ) -> (T, bitfun_app_server_protocol::event::EventCursor) { + let _ordered = self + .permission_order + .lock() + .expect("App Server permission event order poisoned"); + let snapshot = capture(); + let cursor = self.cursor(bitfun_app_server_protocol::event::EventStream::Permission); + (snapshot, cursor) + } + + pub(super) fn forward_permission( + &self, + forward: impl FnOnce(bitfun_app_server_protocol::event::EventCursor) -> T, + ) -> T { + let _ordered = self + .permission_order + .lock() + .expect("App Server permission event order poisoned"); + forward(self.next_cursor(bitfun_app_server_protocol::event::EventStream::Permission)) + } +} + +#[cfg(test)] +mod tests { + use super::ConnectionEventState; + use bitfun_app_server_protocol::event::EventStream; + use std::sync::{mpsc, Arc}; + + #[test] + fn permission_event_started_during_snapshot_stays_after_the_snapshot_watermark() { + let event_state = Arc::new(ConnectionEventState::new()); + let (snapshot_started_tx, snapshot_started_rx) = mpsc::channel(); + let (release_snapshot_tx, release_snapshot_rx) = mpsc::channel(); + let sync_state = event_state.clone(); + let sync = std::thread::spawn(move || { + sync_state.capture_permission_snapshot(|| { + snapshot_started_tx + .send(()) + .expect("snapshot start should be observed"); + release_snapshot_rx + .recv() + .expect("snapshot should be released"); + false + }) + }); + + snapshot_started_rx + .recv() + .expect("snapshot should start before forwarding"); + let (forward_attempted_tx, forward_attempted_rx) = mpsc::channel(); + let (forwarded_tx, forwarded_rx) = mpsc::channel(); + let forward_state = event_state.clone(); + let forward = std::thread::spawn(move || { + forward_attempted_tx + .send(()) + .expect("forward attempt should be observed"); + forward_state.forward_permission(|cursor| { + forwarded_tx + .send(cursor) + .expect("forwarded cursor should be observed"); + }); + }); + + forward_attempted_rx + .recv() + .expect("event forwarder should attempt to publish"); + assert!( + forwarded_rx.try_recv().is_err(), + "permission forwarding must wait for the snapshot watermark" + ); + release_snapshot_tx + .send(()) + .expect("snapshot release should be delivered"); + + let (snapshot_contains_event, watermark) = sync.join().expect("sync thread should finish"); + forward.join().expect("forward thread should finish"); + let event_cursor = forwarded_rx.recv().expect("event should be forwarded"); + + assert_eq!(watermark.stream, EventStream::Permission); + assert!(!snapshot_contains_event); + assert!( + event_cursor.sequence > watermark.sequence, + "an event absent from the snapshot must remain visible after its watermark" + ); + } } /// BitFun agent kernel server over the generic app-server role. #[derive(Clone)] pub struct BitfunAppServer { runtime: Arc, - management: Option>, + transport_limits: TransportLimits, } impl BitfunAppServer { pub fn new(runtime: BitfunAppRuntime) -> Self { Self { runtime: Arc::new(runtime), - management: None, + transport_limits: TransportLimits { + max_frame_bytes: 16 * 1024 * 1024, + event_buffer_capacity: 1024, + }, } } - pub fn with_management(mut self, management: Arc) -> Self { - self.management = Some(management); + /// Override the maximum frame size advertised for this Host transport. + pub fn with_max_frame_bytes(mut self, max_frame_bytes: u64) -> Self { + assert!( + max_frame_bytes > 0, + "App Server frame limit must be positive" + ); + self.transport_limits.max_frame_bytes = max_frame_bytes; self } @@ -109,38 +282,66 @@ impl BitfunAppServer { /// Serve the complete app-server surface on the supplied transport. pub async fn serve(self, transport: impl ConnectTo + 'static) -> Result<()> { let runtime = self.runtime; - let management = self.management; + let transport_limits = self.transport_limits; let event_state = Arc::new(ConnectionEventState::new()); + let protocol_state = handlers::app::ConnectionProtocolState::new(); + let (transport, mut transport_closed) = ConnectionAwareTransport::new(transport); AppServer .builder() .name("bitfun-app-server") - .with_connection_builder(handlers::app::builder( + .with_connection_builder(handlers::app::lifecycle_builder( + protocol_state.clone(), runtime.clone(), - event_state.clone(), - management.clone(), + transport_limits, )) - .with_connection_builder(handlers::agent::builder( + .with_handler(handlers::app::NegotiationGate::new(protocol_state.clone())) + .with_connection_builder(handlers::app::event_sync_builder( runtime.clone(), - management.clone(), + event_state.clone(), )) - .with_connection_builder(handlers::account::builder(management.clone())) + .with_connection_builder(handlers::agent::builder(runtime.clone())) + .with_connection_builder(handlers::account::builder()) .with_connection_builder(handlers::session::builder(runtime.clone())) .with_connection_builder(handlers::permission::builder(runtime.clone())) .with_connection_builder(handlers::workspace::builder(runtime.clone())) - .with_connection_builder(handlers::worktree::builder(management.clone())) - .with_connection_builder(handlers::model::builder(management.clone())) - .with_connection_builder(handlers::skill::builder(management.clone())) - .with_connection_builder(handlers::subagent::builder(management.clone())) - .with_connection_builder(handlers::mcp::builder(management.clone())) - .with_connection_builder(handlers::external_source::builder(management.clone())) - .with_connection_builder(handlers::hook::builder(management.clone())) + .with_connection_builder(handlers::worktree::builder()) + .with_connection_builder(handlers::model::builder()) + .with_connection_builder(handlers::skill::builder()) + .with_connection_builder(handlers::subagent::builder()) + .with_connection_builder(handlers::mcp::builder()) + .with_connection_builder(handlers::external_source::builder()) + .with_connection_builder(handlers::hook::builder()) .with_connection_builder(handlers::git::builder()) .with_connection_builder(handlers::config::builder()) .with_connection_builder(handlers::i18n::builder()) .with_connection_builder(fallback::builder()) .connect_with(transport, async move |cx: ConnectionTo| { - event_forwarder::run(runtime, management, cx, event_state).await + let connection_lifecycle = async move { + match protocol_state.wait_for_decision().await { + handlers::app::ProtocolNegotiation::Accepted => { + let subscriptions = protocol_state + .take_event_subscriptions() + .ok_or_else(|| agent_client_protocol::Error::internal_error())?; + event_forwarder::run(subscriptions, cx, event_state).await + } + handlers::app::ProtocolNegotiation::Rejected => { + // ACP 0.12 has no response-flush acknowledgement. + // Keep handlers alive and universally gated until + // the transport bridge observes peer disconnect. + std::future::pending::>().await + } + handlers::app::ProtocolNegotiation::Pending + | handlers::app::ProtocolNegotiation::Negotiating => unreachable!( + "protocol negotiation wait returned before a decision was recorded" + ), + } + }; + + tokio::select! { + result = connection_lifecycle => result, + _ = transport_closed.changed() => Ok(()), + } }) .await } diff --git a/src/crates/interfaces/app-server/src/server/event_forwarder.rs b/src/crates/interfaces/app-server/src/server/event_forwarder.rs index 42c3c5bc59..33b0b50ad2 100644 --- a/src/crates/interfaces/app-server/src/server/event_forwarder.rs +++ b/src/crates/interfaces/app-server/src/server/event_forwarder.rs @@ -1,27 +1,42 @@ use crate::agent::BitfunAppRuntime; -use crate::management::AppManagementService; use crate::role::AppClient; use crate::schema::{ ConfigEventNotification, EventStream, EventStreamState, EventStreamStateNotification, PermissionEventNotification, ResyncDirective, SessionEventNotification, }; use agent_client_protocol::{ConnectionTo, Result}; -use bitfun_agent_runtime::sdk::PermissionRequestEvent; -use bitfun_app_server_protocol::external_source::ExternalSourceEventNotification; +use bitfun_agent_runtime::sdk::{ + AgentEventReceiver, PermissionRequestEvent, PermissionRequestEventReceiver, +}; use std::sync::Arc; +pub(super) struct EventSubscriptions { + agent: AgentEventReceiver, + permission: Option, + config: + Option>, +} + +impl EventSubscriptions { + pub(super) fn subscribe(runtime: &BitfunAppRuntime) -> Self { + Self { + agent: runtime.event_source().subscribe(), + permission: runtime.runtime().subscribe_permission_requests().ok(), + config: bitfun_core::service::config::subscribe_config_updates(), + } + } +} + pub(super) async fn run( - runtime: Arc, - management: Option>, + subscriptions: EventSubscriptions, cx: ConnectionTo, event_state: Arc, ) -> Result<()> { - let mut rx = runtime.event_source().subscribe(); - let mut permission_rx = runtime.runtime().subscribe_permission_requests().ok(); - let mut config_rx = bitfun_core::service::config::subscribe_config_updates(); - let mut external_source_rx = management - .as_ref() - .map(|management| management.subscribe_external_source_updates()); + let EventSubscriptions { + agent: mut rx, + permission: mut permission_rx, + config: mut config_rx, + } = subscriptions; loop { let permission_recv = async { match &mut permission_rx { @@ -55,18 +70,6 @@ pub(super) async fn run( } } }; - let external_source_recv = async { - match &mut external_source_rx { - Some(receiver) => Some(receiver.recv().await), - None => { - std::future::pending::>>() - .await - } - } - }; tokio::select! { recv = rx.recv() => match recv { Ok(envelope) => { @@ -89,11 +92,10 @@ pub(super) async fn run( }, recv = permission_recv => match recv { Some(Ok(event)) => { - let notification = PermissionEventNotification { - cursor: event_state.next_cursor(EventStream::Permission), - event, - }; - if let Err(error) = cx.send_notification(notification) { + let result = event_state.forward_permission(|cursor| { + cx.send_notification(PermissionEventNotification { cursor, event }) + }); + if let Err(error) = result { log::warn!("App-server permission event forwarder failed to send a notification: {:?} -- skipping this event", error); } } @@ -124,25 +126,6 @@ pub(super) async fn run( } None => {} }, - recv = external_source_recv => match recv { - Some(Ok((workspace_path, snapshot))) => { - if let Err(error) = cx.send_notification(ExternalSourceEventNotification { - cursor: event_state.next_cursor(EventStream::ExternalSource), - workspace_path, - snapshot, - }) { - log::warn!("App-server external source event forwarder failed to send a notification: {:?} -- skipping this event", error); - } - } - Some(Err(tokio::sync::broadcast::error::RecvError::Lagged(missed))) => { - send_stream_state(&cx, &event_state, EventStream::ExternalSource, EventStreamState::Lagged, Some(missed), "externalSource/snapshot", true); - } - Some(Err(tokio::sync::broadcast::error::RecvError::Closed)) => { - send_stream_state(&cx, &event_state, EventStream::ExternalSource, EventStreamState::Closed, None, "externalSource/snapshot", true); - external_source_rx = None; - } - None => {} - } } } Ok(()) diff --git a/src/crates/interfaces/app-server/src/server/handlers/account.rs b/src/crates/interfaces/app-server/src/server/handlers/account.rs index 1aa634aed8..d5835f7d85 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/account.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/account.rs @@ -1,87 +1,46 @@ -use std::sync::Arc; - use agent_client_protocol::{Builder, HandleDispatchFrom}; use bitfun_app_server_protocol::account::*; -use super::capability::management_handler; -use crate::management::{AppManagementService, ACCOUNT_CAPABILITY, SETTINGS_SYNC_CAPABILITY}; +use super::capability::unsupported_management_handler; +use crate::management::{ACCOUNT_CAPABILITY, SETTINGS_SYNC_CAPABILITY}; use crate::role::{AppClient, AppServer}; -pub(in crate::server) fn builder( - management: Option>, -) -> Builder> { +pub(in crate::server) fn builder() -> Builder> { AppServer .builder() .name("account and settings sync handlers") .on_receive_request( - management_handler!( - management, - ACCOUNT_CAPABILITY, - AccountSnapshotRequest, - account_snapshot - ), + unsupported_management_handler!(ACCOUNT_CAPABILITY, AccountSnapshotRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - ACCOUNT_CAPABILITY, - AccountLoginRequest, - account_login - ), + unsupported_management_handler!(ACCOUNT_CAPABILITY, AccountLoginRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - ACCOUNT_CAPABILITY, - AccountFinalizeLoginRequest, - account_finalize_login - ), + unsupported_management_handler!(ACCOUNT_CAPABILITY, AccountFinalizeLoginRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - ACCOUNT_CAPABILITY, - AccountLogoutRequest, - account_logout - ), + unsupported_management_handler!(ACCOUNT_CAPABILITY, AccountLogoutRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - SETTINGS_SYNC_CAPABILITY, - SettingsSyncStartRequest, - settings_sync_start - ), + unsupported_management_handler!(SETTINGS_SYNC_CAPABILITY, SettingsSyncStartRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - SETTINGS_SYNC_CAPABILITY, - SettingsSyncSnapshotRequest, - settings_sync_snapshot - ), + unsupported_management_handler!(SETTINGS_SYNC_CAPABILITY, SettingsSyncSnapshotRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - SETTINGS_SYNC_CAPABILITY, - SettingsSyncCancelRequest, - settings_sync_cancel - ), + unsupported_management_handler!(SETTINGS_SYNC_CAPABILITY, SettingsSyncCancelRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, + unsupported_management_handler!( SETTINGS_SYNC_CAPABILITY, - SettingsSyncLocalChangedRequest, - settings_sync_local_changed + SettingsSyncLocalChangedRequest ), agent_client_protocol::on_receive_request!(), ) diff --git a/src/crates/interfaces/app-server/src/server/handlers/agent.rs b/src/crates/interfaces/app-server/src/server/handlers/agent.rs index fc3d29668e..a16b4f4c29 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/agent.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/agent.rs @@ -7,27 +7,21 @@ use bitfun_app_server_protocol::agent::{ SteerTurnRequest, SteerTurnResponse, SubmitUserAnswersRequest, SubmitUserAnswersResponse, }; -use super::capability::management_handler; +use super::capability::unsupported_management_handler; use crate::agent::{runtime_call, BitfunAppRuntime}; -use crate::management::{AppManagementService, MODES_CAPABILITY}; +use crate::management::MODES_CAPABILITY; use crate::role::{AppClient, AppServer}; use crate::schema::*; use crate::server::wire; pub(in crate::server) fn builder( runtime: Arc, - management: Option>, ) -> Builder> { AppServer .builder() .name("agent handlers") .on_receive_request( - management_handler!( - management, - MODES_CAPABILITY, - ListAgentModesRequest, - list_agent_modes - ), + unsupported_management_handler!(MODES_CAPABILITY, ListAgentModesRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( @@ -177,7 +171,10 @@ pub(in crate::server) fn builder( runtime .runtime() .submit_user_answers(AgentUserAnswersRequest { + session_id: request.session_id, + turn_id: request.turn_id, tool_id: request.tool_id, + registration_sequence: request.registration_sequence, answers: request.answers, }) .await, diff --git a/src/crates/interfaces/app-server/src/server/handlers/app.rs b/src/crates/interfaces/app-server/src/server/handlers/app.rs index 21a6abf1fe..f05ca6ded5 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/app.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/app.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::{Builder, Error, HandleDispatchFrom}; +use agent_client_protocol::{Builder, ConnectionTo, Dispatch, Error, HandleDispatchFrom, Handled}; use bitfun_app_server_protocol::app::{ CapabilityAvailability, CapabilityDescriptor, HealthRequest, HealthResponse, HealthStatus, InitializeRequest, InitializeResponse, ServerInfo, TransportLimits, @@ -10,54 +10,202 @@ use bitfun_app_server_protocol::{MIN_PROTOCOL_VERSION, PROTOCOL_VERSION}; use crate::management::EXTERNAL_SOURCES_CAPABILITY; use crate::role::{AppClient, AppServer}; -const MAX_FRAME_BYTES: u64 = 16 * 1024 * 1024; -const EVENT_BUFFER_CAPACITY: u32 = 1024; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::server) enum ProtocolNegotiation { + Pending, + Negotiating, + Accepted, + Rejected, +} + +#[derive(Clone)] +pub(in crate::server) struct ConnectionProtocolState { + decision: tokio::sync::watch::Sender, + event_subscriptions: std::sync::Arc< + std::sync::Mutex>, + >, +} + +impl ConnectionProtocolState { + pub(in crate::server) fn new() -> Self { + let (decision, _) = tokio::sync::watch::channel(ProtocolNegotiation::Pending); + Self { + decision, + event_subscriptions: std::sync::Arc::new(std::sync::Mutex::new(None)), + } + } + + fn current(&self) -> ProtocolNegotiation { + *self.decision.borrow() + } + + fn accept(&self) -> bool { + self.decision.send_if_modified(|decision| { + if *decision != ProtocolNegotiation::Negotiating { + return false; + } + *decision = ProtocolNegotiation::Accepted; + true + }) + } + + fn begin_negotiation( + &self, + subscriptions: crate::server::event_forwarder::EventSubscriptions, + ) -> bool { + let mut subscriptions = Some(subscriptions); + self.decision.send_if_modified(|decision| { + if *decision != ProtocolNegotiation::Pending { + return false; + } + *self + .event_subscriptions + .lock() + .expect("App Server event subscription state poisoned") = subscriptions.take(); + *decision = ProtocolNegotiation::Negotiating; + true + }) + } + + fn abort_negotiation(&self) { + self.decision.send_if_modified(|decision| { + if *decision != ProtocolNegotiation::Negotiating { + return false; + } + self.event_subscriptions + .lock() + .expect("App Server event subscription state poisoned") + .take(); + *decision = ProtocolNegotiation::Rejected; + true + }); + } + + pub(in crate::server) fn take_event_subscriptions( + &self, + ) -> Option { + self.event_subscriptions + .lock() + .expect("App Server event subscription state poisoned") + .take() + } -pub(in crate::server) fn builder( + fn reject(&self) { + self.decision.send_if_modified(|decision| { + if *decision != ProtocolNegotiation::Pending { + return false; + } + *decision = ProtocolNegotiation::Rejected; + true + }); + } + + pub(in crate::server) async fn wait_for_decision(&self) -> ProtocolNegotiation { + let mut decision = self.decision.subscribe(); + loop { + let current = *decision.borrow_and_update(); + match current { + ProtocolNegotiation::Accepted | ProtocolNegotiation::Rejected => return current, + ProtocolNegotiation::Pending | ProtocolNegotiation::Negotiating => {} + } + if decision.changed().await.is_err() { + return ProtocolNegotiation::Rejected; + } + } + } +} + +pub(in crate::server) struct NegotiationGate { + protocol_state: ConnectionProtocolState, +} + +impl NegotiationGate { + pub(in crate::server) fn new(protocol_state: ConnectionProtocolState) -> Self { + Self { protocol_state } + } +} + +impl HandleDispatchFrom for NegotiationGate { + async fn handle_dispatch_from( + &mut self, + message: Dispatch, + _cx: ConnectionTo, + ) -> Result, Error> { + match (self.protocol_state.current(), message) { + (_, message @ Dispatch::Response(..)) | (ProtocolNegotiation::Accepted, message) => { + Ok(Handled::No { + message, + retry: false, + }) + } + (_, Dispatch::Request(_, responder)) => { + responder.respond_with_error(protocol_negotiation_error())?; + Ok(Handled::Yes) + } + (_, Dispatch::Notification(_)) => Ok(Handled::Yes), + } + } + + fn describe_chain(&self) -> impl std::fmt::Debug { + "AppServerProtocolNegotiationGate" + } +} + +pub(in crate::server) fn lifecycle_builder( + protocol_state: ConnectionProtocolState, runtime: std::sync::Arc, - event_state: std::sync::Arc, - management: Option>, + transport_limits: TransportLimits, ) -> Builder> { - let capabilities = registered_capabilities(management.as_deref()); - let external_source_snapshot_available = capabilities.iter().any(|capability| { - capability.id == EXTERNAL_SOURCES_CAPABILITY - && matches!(capability.availability, CapabilityAvailability::Available) - }); + let initialize_state = protocol_state.clone(); + let health_state = protocol_state; + let capabilities = registered_capabilities(permission_owner_available(&runtime)); AppServer .builder() .name("app lifecycle handlers") .on_receive_request( async move |request: InitializeRequest, responder, _cx| { + if initialize_state.current() != ProtocolNegotiation::Pending { + return responder.respond_with_result(Err(protocol_negotiation_error())); + } if request.protocol_version < MIN_PROTOCOL_VERSION || request.protocol_version > PROTOCOL_VERSION { - return responder.respond_with_result(Err(Error::invalid_params().data( - serde_json::to_value(AppServerErrorData { - kind: AppServerErrorKind::InvalidRequest, - retryable: false, - outcome_unknown: false, - capability: Some("app.initialize".to_string()), - request_id: None, - }) - .unwrap_or(serde_json::Value::Null), - ))); + let result = responder.respond_with_result(Err(protocol_negotiation_error())); + if result.is_ok() { + initialize_state.reject(); + } + return result; + } + let subscriptions = + crate::server::event_forwarder::EventSubscriptions::subscribe(&runtime); + if !initialize_state.begin_negotiation(subscriptions) { + return responder.respond_with_result(Err(protocol_negotiation_error())); } - responder.respond_with_result(Ok(InitializeResponse::new( + let result = responder.respond_with_result(Ok(InitializeResponse::new( ServerInfo { name: "bitfun-app-server".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), }, capabilities.clone(), - TransportLimits { - max_frame_bytes: MAX_FRAME_BYTES, - event_buffer_capacity: EVENT_BUFFER_CAPACITY, - }, - ))) + transport_limits.clone(), + ))); + if result.is_ok() { + debug_assert!( + initialize_state.accept(), + "successful App Server initialize must finalize Negotiating as Accepted" + ); + } else { + initialize_state.abort_negotiation(); + } + result }, agent_client_protocol::on_receive_request!(), ) .on_receive_request( async move |_: HealthRequest, responder, _cx| { + if health_state.current() == ProtocolNegotiation::Rejected { + return responder.respond_with_result(Err(protocol_negotiation_error())); + } responder.respond(HealthResponse { status: HealthStatus::Ready, protocol_version: PROTOCOL_VERSION, @@ -65,17 +213,39 @@ pub(in crate::server) fn builder( }, agent_client_protocol::on_receive_request!(), ) +} + +pub(in crate::server) fn event_sync_builder( + runtime: std::sync::Arc, + event_state: std::sync::Arc, +) -> Builder> { + let capabilities = registered_capabilities(permission_owner_available(&runtime)); + let external_source_snapshot_available = capabilities.iter().any(|capability| { + capability.id == EXTERNAL_SOURCES_CAPABILITY + && matches!(capability.availability, CapabilityAvailability::Available) + }); + AppServer + .builder() + .name("app event synchronization handlers") .on_receive_request( async move |request: SyncEventsRequest, responder, _cx| { - let pending_permissions = runtime - .runtime() - .pending_permission_requests() - .unwrap_or_default(); + let (pending_permissions, permission_cursor) = event_state + .capture_permission_snapshot(|| { + runtime.runtime().pending_permission_requests() + }); + let pending_permissions = crate::agent::runtime_call(pending_permissions)?; responder.respond(SyncEventsResponse { cursors: request .streams .into_iter() - .map(|stream| event_state.cursor(stream)) + .map(|stream| { + if stream == bitfun_app_server_protocol::event::EventStream::Permission + { + permission_cursor.clone() + } else { + event_state.cursor(stream) + } + }) .collect(), pending_permissions, agent_snapshot_available: false, @@ -87,9 +257,24 @@ pub(in crate::server) fn builder( ) } -fn registered_capabilities( - management: Option<&crate::management::AppManagementService>, -) -> Vec { +fn protocol_negotiation_error() -> Error { + Error::invalid_params().data( + serde_json::to_value(AppServerErrorData { + kind: AppServerErrorKind::InvalidRequest, + retryable: false, + outcome_unknown: false, + capability: Some("app.initialize".to_string()), + request_id: None, + }) + .unwrap_or(serde_json::Value::Null), + ) +} + +fn permission_owner_available(runtime: &crate::agent::BitfunAppRuntime) -> bool { + runtime.runtime().pending_permission_requests().is_ok() +} + +fn registered_capabilities(permission_owner_available: bool) -> Vec { let mut capabilities = [ ( "agent", @@ -188,21 +373,26 @@ fn registered_capabilities( ("eventSync", vec!["app/syncEvents", "app/eventStreamState"]), ] .into_iter() - .map(|(id, methods)| CapabilityDescriptor { - id: id.to_string(), - availability: CapabilityAvailability::Available, - methods: methods.into_iter().map(str::to_string).collect(), + .map(|(id, methods)| { + let availability = if id == "permission" && !permission_owner_available { + CapabilityAvailability::Unavailable { + reason: "The Runtime did not provide a Permission request manager".to_string(), + } + } else { + CapabilityAvailability::Available + }; + CapabilityDescriptor { + id: id.to_string(), + availability, + methods: methods.into_iter().map(str::to_string).collect(), + } }) .collect::>(); capabilities.extend( - management - .map(|service| service.capabilities()) - .unwrap_or_else(|| { - crate::management::AppManagementCapabilities::unavailable( - "The Host did not provide management owners", - ) - }) - .descriptors(), + crate::management::AppManagementCapabilities::unavailable( + "The Host did not provide management owners", + ) + .descriptors(), ); capabilities } @@ -212,8 +402,8 @@ mod tests { use super::*; #[test] - fn missing_host_management_service_declares_capabilities_unavailable() { - let capabilities = registered_capabilities(None); + fn host_without_management_owners_declares_capabilities_unavailable() { + let capabilities = registered_capabilities(true); for id in [ "tui.modes", "tui.models", diff --git a/src/crates/interfaces/app-server/src/server/handlers/capability.rs b/src/crates/interfaces/app-server/src/server/handlers/capability.rs index 8d6052d2c6..6dcd4fd6ed 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/capability.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/capability.rs @@ -1,125 +1,25 @@ use agent_client_protocol::Error; -use bitfun_app_server_protocol::app::CapabilityAvailability; use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; -use bitfun_app_server_protocol::external_source::ExternalSourceErrorData; -use bitfun_app_server_protocol::worktree::{ - WorktreeErrorCode, WorktreeErrorData, WorktreeOperationError, -}; -use bitfun_product_domains::external_sources::{ - ExternalSourceOperationError, ExternalSourceOperationErrorCode, -}; -use crate::management::{AppManagementError, AppManagementErrorKind, AppManagementService}; - -macro_rules! management_handler { - ($management:ident, $capability:expr, $request:ty, $method:ident) => {{ - let management = $management.clone(); - async move |request: $request, responder, _cx| { - let management = crate::server::handlers::capability::require_management( - management.as_deref(), +macro_rules! unsupported_management_handler { + ($capability:expr, $request:ty) => {{ + async move |_: $request, responder, _cx| { + responder.respond_with_result(Err(crate::server::handlers::capability::unsupported( $capability, - )?; - responder.respond_with_result(management.$method(request).await.map_err(|error| { - crate::server::handlers::capability::management_error($capability, error) - })) + ))) } }}; } -pub(super) use management_handler; - -pub(super) fn require_management<'a>( - management: Option<&'a AppManagementService>, - capability: &str, -) -> Result<&'a AppManagementService, Error> { - let management = management.ok_or_else(|| unsupported(capability))?; - match management.capabilities().availability(capability) { - Some(CapabilityAvailability::Available) => Ok(management), - Some(CapabilityAvailability::Unavailable { .. }) | None => Err(unsupported(capability)), - } -} +pub(super) use unsupported_management_handler; -fn unsupported(capability: &str) -> Error { - error_with_data( - AppServerErrorKind::Unsupported, - capability, +pub(super) fn unsupported(capability: &str) -> Error { + Error::new( + AppServerErrorKind::Unsupported.json_rpc_code() as i32, "The Host does not provide this management capability", ) -} - -pub(super) fn management_error(capability: &str, error: AppManagementError) -> Error { - if let Some(external) = ExternalSourceOperationError::decode(&error.message) { - return external_management_error(capability, external); - } - if let Some(worktree) = WorktreeOperationError::decode(&error.message) { - return worktree_management_error(capability, worktree); - } - match error.kind { - AppManagementErrorKind::Unsupported => { - error_with_data(AppServerErrorKind::Unsupported, capability, error.message) - } - AppManagementErrorKind::InvalidRequest => Error::invalid_params().data(error.message), - AppManagementErrorKind::NotFound => Error::resource_not_found(None).data(error.message), - AppManagementErrorKind::Internal => Error::internal_error().data(error.message), - } -} - -fn worktree_management_error(capability: &str, error: WorktreeOperationError) -> Error { - let kind = match error.code { - WorktreeErrorCode::RemoteUnsupported => AppServerErrorKind::Unsupported, - WorktreeErrorCode::InvalidPath - | WorktreeErrorCode::InvalidBaseRef - | WorktreeErrorCode::RequestConflict => AppServerErrorKind::InvalidRequest, - WorktreeErrorCode::WorktreeNotFound => AppServerErrorKind::Internal, - WorktreeErrorCode::WorktreeBusy - | WorktreeErrorCode::WorktreeLocked - | WorktreeErrorCode::DirtyWorktree - | WorktreeErrorCode::UnpublishedCommits => AppServerErrorKind::SessionInUse, - _ => AppServerErrorKind::Internal, - }; - let app = AppServerErrorData { - kind, - retryable: false, - outcome_unknown: false, - capability: Some(capability.to_string()), - request_id: error.operation_id.clone(), - }; - Error::new(kind.json_rpc_code() as i32, error.message.clone()).data( - serde_json::to_value(WorktreeErrorData { app, error }).unwrap_or(serde_json::Value::Null), - ) -} - -fn external_management_error(capability: &str, error: ExternalSourceOperationError) -> Error { - let kind = match error.code { - ExternalSourceOperationErrorCode::StaleRevision => AppServerErrorKind::StaleRevision, - ExternalSourceOperationErrorCode::HostCapabilityUnavailable - | ExternalSourceOperationErrorCode::Unsupported => AppServerErrorKind::Unsupported, - ExternalSourceOperationErrorCode::InvalidRequest => AppServerErrorKind::InvalidRequest, - ExternalSourceOperationErrorCode::Timeout | ExternalSourceOperationErrorCode::Cancelled => { - AppServerErrorKind::Internal - } - _ => AppServerErrorKind::Internal, - }; - let app = AppServerErrorData { - kind, - retryable: error.retryable, - outcome_unknown: matches!(kind, AppServerErrorKind::OutcomeUnknown), - capability: Some(capability.to_string()), - request_id: error.correlation_id.clone(), - }; - Error::new(kind.json_rpc_code() as i32, error.detail.clone()).data( - serde_json::to_value(ExternalSourceErrorData { app, error }) - .unwrap_or(serde_json::Value::Null), - ) -} - -fn error_with_data( - kind: AppServerErrorKind, - capability: &str, - message: impl Into, -) -> Error { - Error::new(kind.json_rpc_code() as i32, message.into()).data( + .data( serde_json::to_value(AppServerErrorData { - kind, + kind: AppServerErrorKind::Unsupported, retryable: false, outcome_unknown: false, capability: Some(capability.to_string()), @@ -132,14 +32,11 @@ fn error_with_data( #[cfg(test)] mod tests { use super::*; - use crate::management::{EXTERNAL_SOURCES_CAPABILITY, MODELS_CAPABILITY, WORKTREES_CAPABILITY}; + use crate::management::MODELS_CAPABILITY; #[test] - fn missing_management_service_returns_structured_unsupported_without_fallback() { - let error = match require_management(None, MODELS_CAPABILITY) { - Ok(_) => panic!("management service should be required"), - Err(error) => error, - }; + fn unavailable_management_method_returns_structured_error_without_fallback() { + let error = unsupported(MODELS_CAPABILITY); let data: AppServerErrorData = serde_json::from_value( error .data @@ -152,107 +49,4 @@ mod tests { assert!(!data.retryable); assert!(!data.outcome_unknown); } - - #[test] - fn external_stale_error_preserves_domain_recovery_contract() { - let domain = ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::StaleRevision, - "The external source catalog changed", - false, - ) - .with_correlation_id("external-source-ref-4") - .with_default_recovery_actions(); - let error = external_management_error(EXTERNAL_SOURCES_CAPABILITY, domain.clone()); - let data: ExternalSourceErrorData = serde_json::from_value( - error - .data - .expect("external source error should carry structured data"), - ) - .expect("parse external source error data"); - - assert_eq!(data.app.kind, AppServerErrorKind::StaleRevision); - assert_eq!(data.app.retryable, domain.retryable); - assert!(!data.app.outcome_unknown); - assert_eq!( - data.app.request_id.as_deref(), - Some("external-source-ref-4") - ); - assert_eq!( - data.error.code, - ExternalSourceOperationErrorCode::StaleRevision - ); - assert_eq!(data.error.recovery_actions, domain.recovery_actions); - } - - #[test] - fn owner_timeout_is_not_misreported_as_unknown_transport_outcome() { - let domain = ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::Timeout, - "External source discovery timed out", - true, - ); - let error = external_management_error(EXTERNAL_SOURCES_CAPABILITY, domain); - let data: ExternalSourceErrorData = serde_json::from_value( - error - .data - .expect("external source error should carry structured data"), - ) - .expect("parse external source error data"); - - assert_eq!(data.app.kind, AppServerErrorKind::Internal); - assert!(data.app.retryable); - assert!(!data.app.outcome_unknown); - assert_eq!(data.error.code, ExternalSourceOperationErrorCode::Timeout); - } - - #[test] - fn worktree_remote_unsupported_preserves_typed_operation_context() { - let domain = WorktreeOperationError { - code: WorktreeErrorCode::RemoteUnsupported, - message: "Managed worktrees are not supported for remote workspaces".to_string(), - recovery_path: None, - operation_id: Some("tui-worktree-remote-1".to_string()), - }; - let error = management_error( - WORKTREES_CAPABILITY, - AppManagementError::internal(domain.encode()), - ); - let data: WorktreeErrorData = serde_json::from_value( - error - .data - .expect("worktree error should carry structured data"), - ) - .expect("parse worktree error data"); - - assert_eq!(data.app.kind, AppServerErrorKind::Unsupported); - assert_eq!(data.app.capability.as_deref(), Some(WORKTREES_CAPABILITY)); - assert_eq!( - data.app.request_id.as_deref(), - Some("tui-worktree-remote-1") - ); - assert_eq!(data.error.code, WorktreeErrorCode::RemoteUnsupported); - } - - #[test] - fn worktree_busy_maps_to_session_in_use() { - let domain = WorktreeOperationError { - code: WorktreeErrorCode::WorktreeBusy, - message: "The session is still active".to_string(), - recovery_path: None, - operation_id: Some("tui-worktree-busy-1".to_string()), - }; - let error = management_error( - WORKTREES_CAPABILITY, - AppManagementError::internal(domain.encode()), - ); - let data: WorktreeErrorData = serde_json::from_value( - error - .data - .expect("worktree busy error should carry structured data"), - ) - .expect("parse worktree busy error data"); - - assert_eq!(data.app.kind, AppServerErrorKind::SessionInUse); - assert_eq!(data.error.code, WorktreeErrorCode::WorktreeBusy); - } } diff --git a/src/crates/interfaces/app-server/src/server/handlers/external_source.rs b/src/crates/interfaces/app-server/src/server/handlers/external_source.rs index 70437c6b82..928464f25b 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/external_source.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/external_source.rs @@ -1,60 +1,46 @@ -use std::sync::Arc; - use agent_client_protocol::{Builder, HandleDispatchFrom}; use bitfun_app_server_protocol::external_source::*; -use super::capability::management_handler; -use crate::management::{AppManagementService, EXTERNAL_SOURCES_CAPABILITY}; +use super::capability::unsupported_management_handler; +use crate::management::EXTERNAL_SOURCES_CAPABILITY; use crate::role::{AppClient, AppServer}; -pub(in crate::server) fn builder( - management: Option>, -) -> Builder> { +pub(in crate::server) fn builder() -> Builder> { AppServer .builder() .name("external source handlers") .on_receive_request( - management_handler!( - management, + unsupported_management_handler!( EXTERNAL_SOURCES_CAPABILITY, - ExternalSourceSnapshotRequest, - external_source_snapshot + ExternalSourceSnapshotRequest ), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, + unsupported_management_handler!( EXTERNAL_SOURCES_CAPABILITY, - ExternalSourceControlRequest, - external_source_control + ExternalSourceControlRequest ), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, + unsupported_management_handler!( EXTERNAL_SOURCES_CAPABILITY, - ExternalSourceReviewRequest, - external_source_review + ExternalSourceReviewRequest ), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, + unsupported_management_handler!( EXTERNAL_SOURCES_CAPABILITY, - SetNativeCommandChoiceRequest, - set_native_command_choice + SetNativeCommandChoiceRequest ), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, + unsupported_management_handler!( EXTERNAL_SOURCES_CAPABILITY, - ExpandExternalCommandRequest, - expand_external_command + ExpandExternalCommandRequest ), agent_client_protocol::on_receive_request!(), ) diff --git a/src/crates/interfaces/app-server/src/server/handlers/hook.rs b/src/crates/interfaces/app-server/src/server/handlers/hook.rs index 143a783264..6b8a00ecd9 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/hook.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/hook.rs @@ -1,61 +1,32 @@ -use std::sync::Arc; - use agent_client_protocol::{Builder, HandleDispatchFrom}; use bitfun_app_server_protocol::hook::*; -use super::capability::management_handler; -use crate::management::{AppManagementService, EXTERNAL_HOOKS_CAPABILITY, NATIVE_HOOKS_CAPABILITY}; +use super::capability::unsupported_management_handler; +use crate::management::{EXTERNAL_HOOKS_CAPABILITY, NATIVE_HOOKS_CAPABILITY}; use crate::role::{AppClient, AppServer}; -pub(in crate::server) fn builder( - management: Option>, -) -> Builder> { +pub(in crate::server) fn builder() -> Builder> { AppServer .builder() .name("hook handlers") .on_receive_request( - management_handler!( - management, - NATIVE_HOOKS_CAPABILITY, - NativeHookOverviewRequest, - native_hook_overview - ), + unsupported_management_handler!(NATIVE_HOOKS_CAPABILITY, NativeHookOverviewRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - EXTERNAL_HOOKS_CAPABILITY, - ExternalHookSnapshotRequest, - external_hook_snapshot - ), + unsupported_management_handler!(EXTERNAL_HOOKS_CAPABILITY, ExternalHookSnapshotRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - EXTERNAL_HOOKS_CAPABILITY, - ExternalHookPlanRequest, - external_hook_plan - ), + unsupported_management_handler!(EXTERNAL_HOOKS_CAPABILITY, ExternalHookPlanRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - EXTERNAL_HOOKS_CAPABILITY, - ExternalHookApplyRequest, - external_hook_apply - ), + unsupported_management_handler!(EXTERNAL_HOOKS_CAPABILITY, ExternalHookApplyRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - EXTERNAL_HOOKS_CAPABILITY, - ExternalHookMutationRequest, - external_hook_mutate - ), + unsupported_management_handler!(EXTERNAL_HOOKS_CAPABILITY, ExternalHookMutationRequest), agent_client_protocol::on_receive_request!(), ) } diff --git a/src/crates/interfaces/app-server/src/server/handlers/mcp.rs b/src/crates/interfaces/app-server/src/server/handlers/mcp.rs index 86abf39a46..a47088abc8 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/mcp.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/mcp.rs @@ -1,70 +1,36 @@ -use std::sync::Arc; - use agent_client_protocol::{Builder, HandleDispatchFrom}; use bitfun_app_server_protocol::mcp::*; -use super::capability::management_handler; -use crate::management::{AppManagementService, MCP_CAPABILITY}; +use super::capability::unsupported_management_handler; +use crate::management::MCP_CAPABILITY; use crate::role::{AppClient, AppServer}; -pub(in crate::server) fn builder( - management: Option>, -) -> Builder> { +pub(in crate::server) fn builder() -> Builder> { AppServer .builder() .name("mcp handlers") .on_receive_request( - management_handler!( - management, - MCP_CAPABILITY, - ListMcpServersRequest, - list_mcp_servers - ), + unsupported_management_handler!(MCP_CAPABILITY, ListMcpServersRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - MCP_CAPABILITY, - ToggleMcpServerRequest, - toggle_mcp_server - ), + unsupported_management_handler!(MCP_CAPABILITY, ToggleMcpServerRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - MCP_CAPABILITY, - AddMcpServerRequest, - add_mcp_server - ), + unsupported_management_handler!(MCP_CAPABILITY, AddMcpServerRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - MCP_CAPABILITY, - DeleteMcpServerRequest, - delete_mcp_server - ), + unsupported_management_handler!(MCP_CAPABILITY, DeleteMcpServerRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - MCP_CAPABILITY, - ExternalMcpDecisionRequest, - external_mcp_decision - ), + unsupported_management_handler!(MCP_CAPABILITY, ExternalMcpDecisionRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - MCP_CAPABILITY, - McpConflictChoiceRequest, - mcp_conflict_choice - ), + unsupported_management_handler!(MCP_CAPABILITY, McpConflictChoiceRequest), agent_client_protocol::on_receive_request!(), ) } diff --git a/src/crates/interfaces/app-server/src/server/handlers/model.rs b/src/crates/interfaces/app-server/src/server/handlers/model.rs index 4229041b34..9394c8f25a 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/model.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/model.rs @@ -1,78 +1,44 @@ -use std::sync::Arc; - use agent_client_protocol::{Builder, HandleDispatchFrom}; use bitfun_app_server_protocol::model::*; -use super::capability::management_handler; -use crate::management::{AppManagementService, MODELS_CAPABILITY}; +use super::capability::unsupported_management_handler; +use crate::management::MODELS_CAPABILITY; use crate::role::{AppClient, AppServer}; -pub(in crate::server) fn builder( - management: Option>, -) -> Builder> { +pub(in crate::server) fn builder() -> Builder> { AppServer .builder() .name("model handlers") .on_receive_request( - management_handler!( - management, - MODELS_CAPABILITY, - ProjectReasoningCatalogRequest, - project_reasoning_catalog - ), + unsupported_management_handler!(MODELS_CAPABILITY, ProjectReasoningCatalogRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - MODELS_CAPABILITY, - TuiModelCatalogRequest, - tui_model_catalog - ), + unsupported_management_handler!(MODELS_CAPABILITY, TuiModelCatalogRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - MODELS_CAPABILITY, - ListModelsRequest, - list_models - ), + unsupported_management_handler!(MODELS_CAPABILITY, ListModelsRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!(management, MODELS_CAPABILITY, GetModelRequest, get_model), + unsupported_management_handler!(MODELS_CAPABILITY, GetModelRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!(management, MODELS_CAPABILITY, AddModelRequest, add_model), + unsupported_management_handler!(MODELS_CAPABILITY, AddModelRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - MODELS_CAPABILITY, - UpdateModelRequest, - update_model - ), + unsupported_management_handler!(MODELS_CAPABILITY, UpdateModelRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - MODELS_CAPABILITY, - DeleteModelRequest, - delete_model - ), + unsupported_management_handler!(MODELS_CAPABILITY, DeleteModelRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - MODELS_CAPABILITY, - SetModelDefaultRequest, - set_model_default - ), + unsupported_management_handler!(MODELS_CAPABILITY, SetModelDefaultRequest), agent_client_protocol::on_receive_request!(), ) } diff --git a/src/crates/interfaces/app-server/src/server/handlers/session.rs b/src/crates/interfaces/app-server/src/server/handlers/session.rs index 7c45345776..deb6471cbe 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/session.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/session.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use agent_client_protocol::{Builder, Error, HandleDispatchFrom}; +use agent_client_protocol::{Builder, HandleDispatchFrom}; use bitfun_agent_runtime::sdk::AgentSessionRestoreRequest; use bitfun_app_server_protocol::session::{ CancelLineageRequest, CancelLineageResponse, CompactSessionRequest, CompactSessionResponse, @@ -169,6 +169,8 @@ pub(in crate::server) fn builder( async move |request: SyncSessionRequest, responder, _cx| { let session_id = request.session_id.clone(); let workspace_path = request.workspace_path.clone(); + let remote_scope = + request.remote_connection_id.is_some() || request.remote_ssh_host.is_some(); let restored = runtime .runtime() .restore_session(AgentSessionRestoreRequest { @@ -182,17 +184,6 @@ pub(in crate::server) fn builder( .map_err(|error| { BitfunAppRuntime::session_runtime_error(&session_id, error) })?; - let transcript = runtime_call( - runtime - .runtime() - .read_session_transcript( - bitfun_runtime_ports::SessionTranscriptRequest { - session_id: session_id.clone(), - turn_id: None, - }, - ) - .await, - )?; let workspace_binding = runtime_call( runtime .runtime() @@ -202,22 +193,27 @@ pub(in crate::server) fn builder( }, ) .await, - )? - .unwrap_or_else(|| fallback_workspace_binding(workspace_path)); - let pending_permissions = runtime - .runtime() - .pending_permission_requests() - .unwrap_or_default() - .into_iter() - .filter(|permission| permission.session_id == session_id) - .collect(); + )?; + let workspace_binding = match workspace_binding { + Some(binding) => binding, + None if remote_scope => { + return Err(super::capability::unsupported("remote_workspace_binding")); + } + None => fallback_workspace_binding(workspace_path), + }; + let pending_permissions = + runtime_call(runtime.runtime().pending_permission_requests())? + .into_iter() + .filter(|permission| permission.session_id == session_id) + .collect(); responder.respond(SyncSessionResponse { session: restored.session, state: wire::session_state(restored.state), - transcript, + transcript: restored.transcript, workspace_binding, pending_permissions, + pending_user_inputs: restored.pending_user_inputs, }) } }, @@ -314,13 +310,17 @@ pub(in crate::server) fn builder( { let runtime = runtime.clone(); async move |request: ReloadContextRequest, responder, _cx| { - let port = runtime.context_reload().ok_or_else(|| { - Error::internal_error().data("session context reload is unavailable") - })?; - port.reload_session_context(request.0) - .await - .map_err(|error| Error::internal_error().data(error.message))?; - responder.respond(ReloadContextResponse {}) + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .reload_context(request.0) + .await + .map(|()| ReloadContextResponse {}) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) } }, agent_client_protocol::on_receive_request!(), @@ -417,3 +417,367 @@ fn fallback_workspace_binding(workspace_path: String) -> AgentSessionWorkspaceBi remote_ssh_host: None, } } + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use async_trait::async_trait; + use bitfun_agent_runtime::event_queue::{EventQueue, EventQueueConfig}; + use bitfun_agent_runtime::sdk::{ + AgentEventSource, AgentRuntimeBuilder, AgentSessionCreateRequest, AgentSessionCreateResult, + AgentSessionDeleteRequest, AgentSessionListRequest, AgentSessionManagementPort, + AgentSessionRestorePort, AgentSessionRestoreRequest, AgentSessionRestoreResult, + AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, + AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, PendingUserInput, + PermissionRequestManager, PortResult, ProcessingPhase, SessionState, + }; + use bitfun_app_server_protocol::app::{ClientInfo, InitializeRequest}; + use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; + use bitfun_app_server_protocol::session::SyncSessionRequest; + use bitfun_app_server_protocol::PROTOCOL_VERSION; + use bitfun_runtime_ports as ports; + use bitfun_runtime_ports::{ + SessionExecutionTarget, SessionTranscript, SessionTranscriptReader, + SessionTranscriptRequest, TranscriptContent, TranscriptMessage, + }; + use tokio::task::LocalSet; + + use crate::{transport, BitfunAppRuntime, BitfunAppServer}; + + #[derive(Debug, Default)] + struct SyncProvider { + transcript_reads: AtomicUsize, + workspace_binding: std::sync::Mutex>, + } + + #[derive(Debug, Default)] + struct TestPermissionStore; + + impl ports::RuntimeServicePort for TestPermissionStore { + fn capability(&self) -> ports::RuntimeServiceCapability { + ports::RuntimeServiceCapability::Permission + } + } + + #[async_trait] + impl ports::PermissionAuditStorePort for TestPermissionStore { + async fn append_permission_audit( + &self, + _record: ports::PermissionAuditRecord, + ) -> PortResult<()> { + Ok(()) + } + + async fn list_project_permission_audit( + &self, + _project_id: &str, + ) -> PortResult> { + Ok(Vec::new()) + } + } + + #[async_trait] + impl ports::PermissionReplyStorePort for TestPermissionStore { + async fn commit_permission_reply( + &self, + _grants: Vec, + _audit: Vec, + ) -> PortResult<()> { + Ok(()) + } + } + + #[derive(Debug)] + struct TestPermissionClock; + + impl ports::RuntimeServicePort for TestPermissionClock { + fn capability(&self) -> ports::RuntimeServiceCapability { + ports::RuntimeServiceCapability::Clock + } + } + + impl ports::ClockPort for TestPermissionClock { + fn now_unix_millis(&self) -> i64 { + 1_778_347_200_000 + } + } + + #[async_trait] + impl AgentSubmissionPort for SyncProvider { + async fn create_session( + &self, + request: AgentSessionCreateRequest, + ) -> PortResult { + Ok(AgentSessionCreateResult::new( + "session-1", + request.session_name, + request.agent_type, + )) + } + + async fn submit_message( + &self, + request: AgentSubmissionRequest, + ) -> PortResult { + Ok(AgentSubmissionResult { + turn_id: request.turn_id.unwrap_or_else(|| "turn-1".to_string()), + accepted: true, + }) + } + + async fn resolve_session_agent_type( + &self, + _session_id: &str, + ) -> PortResult> { + Ok(Some("agentic".to_string())) + } + } + + #[async_trait] + impl AgentSessionRestorePort for SyncProvider { + async fn restore_session( + &self, + _request: AgentSessionRestoreRequest, + ) -> PortResult { + Ok(AgentSessionRestoreResult { + session: AgentSessionSummary { + session_id: "session-1".to_string(), + session_name: "Session".to_string(), + agent_type: "agentic".to_string(), + model_id: None, + reasoning_preset: None, + last_user_dialog_agent_type: None, + last_submitted_agent_type: Some("agentic".to_string()), + turn_count: 1, + created_at_ms: 10, + last_active_at_ms: 20, + }, + state: SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::ToolCalling, + }, + transcript: transcript("restore snapshot"), + pending_user_inputs: vec![PendingUserInput { + tool_id: "question-1".to_string(), + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + source_session_id: "child-session".to_string(), + source_turn_id: "child-turn".to_string(), + registration_sequence: 7, + input: serde_json::json!({ "questions": [{ "question": "Continue?" }] }), + }], + }) + } + } + + #[async_trait] + impl SessionTranscriptReader for SyncProvider { + async fn read_session_transcript( + &self, + _request: SessionTranscriptRequest, + ) -> PortResult { + self.transcript_reads.fetch_add(1, Ordering::SeqCst); + Ok(transcript("stale second read")) + } + } + + #[async_trait] + impl AgentSessionManagementPort for SyncProvider { + async fn list_sessions( + &self, + _request: AgentSessionListRequest, + ) -> PortResult> { + Ok(Vec::new()) + } + + async fn delete_session(&self, _request: AgentSessionDeleteRequest) -> PortResult<()> { + Ok(()) + } + + async fn resolve_session_workspace_binding( + &self, + _request: AgentSessionWorkspaceRequest, + ) -> PortResult> { + Ok(self.workspace_binding.lock().unwrap().clone()) + } + } + + async fn connect_sync_client( + provider: Arc, + with_permissions: bool, + ) -> bitfun_app_server_client::AppServerClient { + let mut builder = AgentRuntimeBuilder::new() + .with_submission_port(provider.clone()) + .with_session_restore_port(provider.clone()) + .with_session_transcript_reader(provider.clone()) + .with_session_management_port(provider); + if with_permissions { + let store = Arc::new(TestPermissionStore); + builder = builder.with_permission_request_manager(Arc::new( + PermissionRequestManager::new(store.clone(), store, Arc::new(TestPermissionClock)), + )); + } + let runtime = builder.build().expect("runtime"); + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let app_runtime = BitfunAppRuntime::new(runtime, AgentEventSource::new(event_queue)); + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + tokio::task::spawn_local(async move { + BitfunAppServer::new(app_runtime) + .serve(server_transport) + .await + .expect("serve app server"); + }); + + let client = bitfun_app_server_client::connect(client_transport) + .await + .expect("connect app server client"); + client + .initialize(InitializeRequest { + protocol_version: PROTOCOL_VERSION, + client: ClientInfo { + name: "session-handler-test".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + }) + .await + .expect("initialize app server client"); + client + } + + #[tokio::test(flavor = "current_thread")] + async fn sync_session_uses_one_restore_snapshot_for_transcript_and_pending_input() { + let local = LocalSet::new(); + local + .run_until(async { + let provider = Arc::new(SyncProvider { + workspace_binding: std::sync::Mutex::new(Some(AgentSessionWorkspaceBinding { + workspace_id: Some("workspace-1".to_string()), + workspace_path: "/workspace".to_string(), + project_workspace_path: Some("/workspace".to_string()), + execution_target: None, + remote_connection_id: None, + remote_ssh_host: None, + })), + ..SyncProvider::default() + }); + let client = connect_sync_client(provider.clone(), true).await; + let response = client + .sync_session(SyncSessionRequest { + workspace_path: "/workspace".to_string(), + session_id: "session-1".to_string(), + include_internal: false, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .expect("sync session"); + + assert_eq!(provider.transcript_reads.load(Ordering::SeqCst), 0); + assert_eq!(response.pending_user_inputs.len(), 1); + assert_eq!(response.pending_user_inputs[0].tool_id, "question-1"); + assert!(matches!( + response.transcript.messages[0].content, + TranscriptContent::Text(ref text) if text == "restore snapshot" + )); + client.shutdown().await; + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn sync_session_fails_closed_when_remote_binding_is_unavailable() { + let local = LocalSet::new(); + local + .run_until(async { + let provider = Arc::new(SyncProvider::default()); + let client = connect_sync_client(provider, true).await; + + let local_sync = client + .sync_session(SyncSessionRequest { + workspace_path: "/local/project".to_string(), + session_id: "session-1".to_string(), + include_internal: false, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .expect("local sync may retain the compatibility fallback"); + assert_eq!( + local_sync.workspace_binding.execution_target, + Some(SessionExecutionTarget::local("/local/project")) + ); + + for (remote_connection_id, remote_ssh_host) in [ + (Some("remote-1".to_string()), None), + (None, Some("host-1".to_string())), + ] { + let error = client + .sync_session(SyncSessionRequest { + workspace_path: "/remote/project".to_string(), + session_id: "session-1".to_string(), + include_internal: false, + remote_connection_id, + remote_ssh_host, + }) + .await + .expect_err("remote sync must not synthesize a local workspace binding"); + assert_eq!( + error.code, + (AppServerErrorKind::Unsupported.json_rpc_code() as i32).into() + ); + let data: AppServerErrorData = serde_json::from_value( + error + .data + .expect("remote binding failure should carry stable error data"), + ) + .expect("remote binding failure data should match the wire contract"); + assert_eq!(data.kind, AppServerErrorKind::Unsupported); + assert!(!data.retryable); + assert!(!data.outcome_unknown); + assert_eq!(data.capability.as_deref(), Some("remote_workspace_binding")); + } + client.shutdown().await; + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn sync_session_fails_closed_without_permission_snapshot_owner() { + let local = LocalSet::new(); + local + .run_until(async { + let client = connect_sync_client(Arc::new(SyncProvider::default()), false).await; + let error = client + .sync_session(SyncSessionRequest { + workspace_path: "/workspace".to_string(), + session_id: "session-1".to_string(), + include_internal: false, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .expect_err("missing permission owner must not look like an empty snapshot"); + assert_eq!( + error.code, + (AppServerErrorKind::Internal.json_rpc_code() as i32).into() + ); + client.shutdown().await; + }) + .await; + } + + fn transcript(text: &str) -> SessionTranscript { + SessionTranscript { + session_id: "session-1".to_string(), + messages: vec![TranscriptMessage { + id: Some("message-1".to_string()), + role: "assistant".to_string(), + turn_id: Some("turn-1".to_string()), + timestamp_ms: None, + content: TranscriptContent::Text(text.to_string()), + }], + } + } +} diff --git a/src/crates/interfaces/app-server/src/server/handlers/skill.rs b/src/crates/interfaces/app-server/src/server/handlers/skill.rs index 88522da6ea..67f148edb8 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/skill.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/skill.rs @@ -1,34 +1,20 @@ -use std::sync::Arc; - use agent_client_protocol::{Builder, HandleDispatchFrom}; use bitfun_app_server_protocol::skill::*; -use super::capability::management_handler; -use crate::management::{AppManagementService, SKILLS_CAPABILITY}; +use super::capability::unsupported_management_handler; +use crate::management::SKILLS_CAPABILITY; use crate::role::{AppClient, AppServer}; -pub(in crate::server) fn builder( - management: Option>, -) -> Builder> { +pub(in crate::server) fn builder() -> Builder> { AppServer .builder() .name("skill handlers") .on_receive_request( - management_handler!( - management, - SKILLS_CAPABILITY, - ListSkillsRequest, - list_skills - ), + unsupported_management_handler!(SKILLS_CAPABILITY, ListSkillsRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - SKILLS_CAPABILITY, - SetSkillEnabledRequest, - set_skill_enabled - ), + unsupported_management_handler!(SKILLS_CAPABILITY, SetSkillEnabledRequest), agent_client_protocol::on_receive_request!(), ) } diff --git a/src/crates/interfaces/app-server/src/server/handlers/subagent.rs b/src/crates/interfaces/app-server/src/server/handlers/subagent.rs index 8900215767..536ce39e18 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/subagent.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/subagent.rs @@ -1,34 +1,20 @@ -use std::sync::Arc; - use agent_client_protocol::{Builder, HandleDispatchFrom}; use bitfun_app_server_protocol::subagent::*; -use super::capability::management_handler; -use crate::management::{AppManagementService, SUBAGENTS_CAPABILITY}; +use super::capability::unsupported_management_handler; +use crate::management::SUBAGENTS_CAPABILITY; use crate::role::{AppClient, AppServer}; -pub(in crate::server) fn builder( - management: Option>, -) -> Builder> { +pub(in crate::server) fn builder() -> Builder> { AppServer .builder() .name("subagent handlers") .on_receive_request( - management_handler!( - management, - SUBAGENTS_CAPABILITY, - ListSubagentsRequest, - list_subagents - ), + unsupported_management_handler!(SUBAGENTS_CAPABILITY, ListSubagentsRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - SUBAGENTS_CAPABILITY, - SetSubagentEnabledRequest, - set_subagent_enabled - ), + unsupported_management_handler!(SUBAGENTS_CAPABILITY, SetSubagentEnabledRequest), agent_client_protocol::on_receive_request!(), ) } diff --git a/src/crates/interfaces/app-server/src/server/handlers/worktree.rs b/src/crates/interfaces/app-server/src/server/handlers/worktree.rs index 5e78bb75a5..874aaf7281 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/worktree.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/worktree.rs @@ -1,43 +1,24 @@ -use std::sync::Arc; - use agent_client_protocol::{Builder, HandleDispatchFrom}; use bitfun_app_server_protocol::worktree::*; -use super::capability::management_handler; -use crate::management::{AppManagementService, WORKTREES_CAPABILITY}; +use super::capability::unsupported_management_handler; +use crate::management::WORKTREES_CAPABILITY; use crate::role::{AppClient, AppServer}; -pub(in crate::server) fn builder( - management: Option>, -) -> Builder> { +pub(in crate::server) fn builder() -> Builder> { AppServer .builder() .name("worktree handlers") .on_receive_request( - management_handler!( - management, - WORKTREES_CAPABILITY, - WorktreeRepositoryStatusRequest, - worktree_repository_status - ), + unsupported_management_handler!(WORKTREES_CAPABILITY, WorktreeRepositoryStatusRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - WORKTREES_CAPABILITY, - WorktreeBindSessionRequest, - worktree_bind_session - ), + unsupported_management_handler!(WORKTREES_CAPABILITY, WorktreeBindSessionRequest), agent_client_protocol::on_receive_request!(), ) .on_receive_request( - management_handler!( - management, - WORKTREES_CAPABILITY, - WorktreeReleaseSessionRequest, - worktree_release_session - ), + unsupported_management_handler!(WORKTREES_CAPABILITY, WorktreeReleaseSessionRequest), agent_client_protocol::on_receive_request!(), ) } diff --git a/src/crates/interfaces/app-server/tests/agent_kernel.rs b/src/crates/interfaces/app-server/tests/agent_kernel.rs index 5745022542..145282f623 100644 --- a/src/crates/interfaces/app-server/tests/agent_kernel.rs +++ b/src/crates/interfaces/app-server/tests/agent_kernel.rs @@ -15,7 +15,10 @@ //! recursion limit when this test instantiates the connection. The lifted //! limit keeps the chain compiling as more host-service groups land. -use std::sync::{Arc, Mutex}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; use std::time::Duration; use agent_client_protocol::{ConnectionTo, ErrorCode, SentRequest}; @@ -32,7 +35,9 @@ use bitfun_agent_runtime::sdk::{ AgentSessionRestoreResult, AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, AgentTurnCancellationRequest, AgenticEvent, - PortResult, ProcessingPhase, SessionState, + PermissionReply, PermissionReplySource, PermissionRequest, PermissionRequestManager, + PermissionRequestSource, PermissionRequestSourceKind, PortResult, ProcessingPhase, + SessionState, }; use bitfun_app_server::schema::{ CancelTurnMessage, CreateSessionMessage, CreateSessionResponse, DeleteSessionMessage, @@ -45,12 +50,18 @@ use bitfun_app_server::schema::{ }; use bitfun_app_server::{transport, AppClient, AppServer, BitfunAppRuntime, BitfunAppServer}; use bitfun_app_server_protocol::agent as protocol_agent; -use bitfun_app_server_protocol::app::{ClientInfo, HealthStatus, InitializeRequest}; -use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; -use bitfun_app_server_protocol::event::{AgentEventNotification, EventStream, SyncEventsRequest}; +use bitfun_app_server_protocol::app::{ + CapabilityAvailability, ClientInfo, HealthRequest, HealthStatus, InitializeRequest, +}; +use bitfun_app_server_protocol::error::{ + AppServerErrorData, AppServerErrorKind, OUTCOME_UNKNOWN_CODE, +}; +use bitfun_app_server_protocol::event::{ + AgentEventNotification, EventStream, PermissionEventNotification, SyncEventsRequest, +}; use bitfun_app_server_protocol::session as protocol_session; use bitfun_app_server_protocol::workspace as protocol_workspace; -use bitfun_app_server_protocol::PROTOCOL_VERSION; +use bitfun_app_server_protocol::{MIN_PROTOCOL_VERSION, PROTOCOL_VERSION}; use bitfun_runtime_ports as ports; use tokio::task::LocalSet; @@ -144,6 +155,123 @@ fn build_app_runtime_with_queue() -> (BitfunAppRuntime, Arc) { ) } +#[derive(Debug, Default)] +struct PermissionAuditGate { + started: tokio::sync::Notify, + release: tokio::sync::Notify, +} + +#[derive(Debug, Default)] +struct TestPermissionStore { + audit_gate: Option>, +} + +impl ports::RuntimeServicePort for TestPermissionStore { + fn capability(&self) -> ports::RuntimeServiceCapability { + ports::RuntimeServiceCapability::Permission + } +} + +#[async_trait] +impl ports::PermissionAuditStorePort for TestPermissionStore { + async fn append_permission_audit( + &self, + _record: ports::PermissionAuditRecord, + ) -> PortResult<()> { + if let Some(gate) = &self.audit_gate { + gate.started.notify_one(); + gate.release.notified().await; + } + Ok(()) + } + + async fn list_project_permission_audit( + &self, + _project_id: &str, + ) -> PortResult> { + Ok(Vec::new()) + } +} + +#[async_trait] +impl ports::PermissionReplyStorePort for TestPermissionStore { + async fn commit_permission_reply( + &self, + _grants: Vec, + _audit: Vec, + ) -> PortResult<()> { + Ok(()) + } +} + +#[derive(Debug)] +struct TestPermissionClock; + +impl ports::RuntimeServicePort for TestPermissionClock { + fn capability(&self) -> ports::RuntimeServiceCapability { + ports::RuntimeServiceCapability::Clock + } +} + +impl ports::ClockPort for TestPermissionClock { + fn now_unix_millis(&self) -> i64 { + 1_778_347_200_000 + } +} + +fn build_app_runtime_with_permissions() -> (BitfunAppRuntime, Arc) { + build_app_runtime_with_permission_store(Arc::new(TestPermissionStore::default())) +} + +fn build_app_runtime_with_permission_store( + store: Arc, +) -> (BitfunAppRuntime, Arc) { + let provider = Arc::new(ExampleAgentProvider::default()); + let permissions = test_permission_manager(store); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(provider.clone()) + .with_dialog_turn_port(provider) + .with_permission_request_manager(permissions.clone()) + .with_event_stream(AgentEventStream::new()) + .build() + .expect("runtime should build with a permission request manager"); + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + ( + BitfunAppRuntime::new(runtime, AgentEventSource::new(event_queue)), + permissions, + ) +} + +fn test_permission_manager(store: Arc) -> Arc { + Arc::new(PermissionRequestManager::new( + store.clone(), + store, + Arc::new(TestPermissionClock), + )) +} + +fn permission_request(request_id: &str) -> PermissionRequest { + PermissionRequest { + request_id: request_id.to_string(), + round_id: "permission-round".to_string(), + order: 0, + tool_call_id: Some("permission-tool".to_string()), + project_path: Some("D:/workspace/project".to_string()), + project_id: "project".to_string(), + session_id: "session".to_string(), + agent_id: "agentic".to_string(), + action: "edit".to_string(), + resources: vec!["src/lib.rs".to_string()], + save_resources: Vec::new(), + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: "write_file".to_string(), + }, + delegation: None, + display_metadata: serde_json::Map::new(), + } +} + #[derive(Debug, Default)] struct SessionControlProvider { renamed: Mutex>, @@ -271,6 +399,11 @@ impl AgentSessionRestorePort for SessionControlProvider { current_turn_id: "turn-active".to_string(), phase: ProcessingPhase::Thinking, }, + transcript: bitfun_runtime_ports::SessionTranscript { + session_id: "session-1".to_string(), + messages: Vec::new(), + }, + pending_user_inputs: Vec::new(), }) } } @@ -304,6 +437,8 @@ struct Phase2Provider { compactions: Mutex>, settlements: Mutex>, reloads: Mutex>, + undo_outcome_unknown: AtomicBool, + reload_outcome_unknown: AtomicBool, } #[async_trait] @@ -391,6 +526,17 @@ impl bitfun_agent_runtime::sdk::AgentSessionRestorePort for Phase2Provider { current_turn_id: "turn-active".to_string(), phase: ProcessingPhase::Streaming, }, + transcript: ports::SessionTranscript { + session_id: "session-1".to_string(), + messages: vec![ports::TranscriptMessage { + id: Some("message-1".to_string()), + role: "assistant".to_string(), + turn_id: Some("turn-1".to_string()), + timestamp_ms: Some(20), + content: ports::TranscriptContent::Text("ready".to_string()), + }], + }, + pending_user_inputs: Vec::new(), }) } } @@ -496,6 +642,12 @@ impl ports::AgentSessionRevertPort for Phase2Provider { &self, request: ports::AgentSessionRevertRequest, ) -> PortResult { + if self.undo_outcome_unknown.load(Ordering::SeqCst) { + return Err(ports::PortError::new( + ports::PortErrorKind::OutcomeUnknown, + "undo may already have been applied", + )); + } Ok(revert_result(request.session_id, "undo restored")) } @@ -624,6 +776,12 @@ impl ports::AgentContextReloadPort for Phase2Provider { &self, request: ports::AgentContextReloadRequest, ) -> PortResult<()> { + if self.reload_outcome_unknown.load(Ordering::SeqCst) { + return Err(ports::PortError::new( + ports::PortErrorKind::OutcomeUnknown, + "context reload may already have been applied", + )); + } self.reloads.lock().unwrap().push(request); Ok(()) } @@ -724,6 +882,7 @@ impl ports::RuntimeEventSink for TestRuntimeEventSink { fn build_phase2_app_runtime() -> (BitfunAppRuntime, Arc) { let provider = Arc::new(Phase2Provider::default()); + let permissions = test_permission_manager(Arc::new(TestPermissionStore::default())); let services = bitfun_agent_runtime::sdk::RuntimeServicesBuilder::new() .with_filesystem(Arc::new(TestRuntimeService( ports::RuntimeServiceCapability::FileSystem, @@ -758,14 +917,15 @@ fn build_phase2_app_runtime() -> (BitfunAppRuntime, Arc) { .with_turn_settlement_port(provider.clone()) .with_workspace_reference_port(provider.clone()) .with_session_lineage_port(provider.clone()) + .with_context_reload_port(provider.clone()) + .with_permission_request_manager(permissions) .with_services(services) .with_event_stream(AgentEventStream::new()) .build() .expect("phase 2 runtime"); let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); ( - BitfunAppRuntime::new(runtime, AgentEventSource::new(event_queue)) - .with_context_reload(provider.clone()), + BitfunAppRuntime::new(runtime, AgentEventSource::new(event_queue)), provider, ) } @@ -782,6 +942,7 @@ async fn phase2_sync_aggregates_authoritative_session_state() { let client = bitfun_app_server_client::connect(client_transport) .await .expect("connect app server client"); + initialize_test_client(&client).await; let response = client .sync_session(protocol_session::SyncSessionRequest { workspace_path: "/requested/workspace".to_string(), @@ -829,6 +990,7 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { let client = bitfun_app_server_client::connect(client_transport) .await .expect("connect app server client"); + initialize_test_client(&client).await; let steer = client .steer_turn(protocol_agent::SteerTurnRequest( ports::AgentDialogSteerRequest { @@ -858,7 +1020,10 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { client .submit_user_answers(protocol_agent::SubmitUserAnswersRequest { + session_id: "session-1".to_string(), + turn_id: "turn-active".to_string(), tool_id: "ask-1".to_string(), + registration_sequence: 1, answers: serde_json::json!({"answer": "yes"}), }) .await @@ -910,7 +1075,13 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { provider.shell_commands.lock().unwrap()[0].command, "cargo test" ); - assert_eq!(provider.answers.lock().unwrap().len(), 1); + let answers = provider.answers.lock().unwrap(); + assert_eq!(answers.len(), 1); + assert_eq!(answers[0].session_id, "session-1"); + assert_eq!(answers[0].turn_id, "turn-active"); + assert_eq!(answers[0].tool_id, "ask-1"); + assert_eq!(answers[0].registration_sequence, 1); + drop(answers); assert_eq!(provider.compactions.lock().unwrap().len(), 1); assert_eq!(provider.reloads.lock().unwrap().len(), 1); client.shutdown().await; @@ -918,6 +1089,68 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { .await; } +#[tokio::test(flavor = "current_thread")] +async fn runtime_outcome_unknown_survives_the_real_session_handlers() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let (runtime, provider) = build_phase2_app_runtime(); + provider.undo_outcome_unknown.store(true, Ordering::SeqCst); + provider + .reload_outcome_unknown + .store(true, Ordering::SeqCst); + spawn_server(runtime, server_transport); + + let client = bitfun_app_server_client::connect(client_transport) + .await + .expect("connect app server client"); + initialize_test_client(&client).await; + let error = client + .undo_session(protocol_session::UndoSessionRequest( + ports::AgentSessionRevertRequest { + workspace_path: "/workspace".to_string(), + session_id: "session-1".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }, + )) + .await + .expect_err("OutcomeUnknown must remain typed at the App Server boundary"); + assert_outcome_unknown_protocol_error(error); + + let error = client + .reload_context(protocol_session::ReloadContextRequest( + ports::AgentContextReloadRequest { + session_id: "session-1".to_string(), + target: ports::AgentContextReloadTarget::All, + }, + )) + .await + .expect_err("reload OutcomeUnknown must remain typed at the App Server boundary"); + assert_outcome_unknown_protocol_error(error); + client.shutdown().await; + }) + .await; +} + +fn assert_outcome_unknown_protocol_error(error: bitfun_app_server_client::ClientError) { + let error = match error { + bitfun_app_server_client::ClientError::Protocol(error) => error, + bitfun_app_server_client::ClientError::Timeout(_) => { + panic!("owner OutcomeUnknown must not be replaced by a client timeout") + } + }; + + assert_eq!(error.code, ErrorCode::Other(OUTCOME_UNKNOWN_CODE as i32)); + let data: AppServerErrorData = + serde_json::from_value(error.data.expect("OutcomeUnknown must carry stable data")) + .expect("OutcomeUnknown data must match the protocol schema"); + assert_eq!(data.kind, AppServerErrorKind::OutcomeUnknown); + assert!(!data.retryable); + assert!(data.outcome_unknown); +} + #[tokio::test(flavor = "current_thread")] async fn phase2_read_models_cover_usage_settlement_references_lineage_and_diff() { let local = LocalSet::new(); @@ -930,6 +1163,7 @@ async fn phase2_read_models_cover_usage_settlement_references_lineage_and_diff() let client = bitfun_app_server_client::connect(client_transport) .await .expect("connect app server client"); + initialize_test_client(&client).await; let usage = client .session_usage(protocol_session::SessionUsageRequest( ports::AgentSessionUsageRequest { @@ -1039,6 +1273,48 @@ where .map_err(|_| agent_client_protocol::Error::internal_error())? } +async fn initialize_connection( + cx: &ConnectionTo, +) -> Result<(), agent_client_protocol::Error> { + let initialized = recv(cx.send_request(InitializeRequest { + protocol_version: PROTOCOL_VERSION, + client: ClientInfo { + name: "bitfun-app-server-test".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + })) + .await?; + assert_eq!(initialized.protocol_version, PROTOCOL_VERSION); + Ok(()) +} + +async fn initialize_test_client(client: &bitfun_app_server_client::AppServerClient) { + let initialized = client + .initialize(InitializeRequest { + protocol_version: PROTOCOL_VERSION, + client: ClientInfo { + name: "bitfun-app-server-test".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + }) + .await + .expect("initialize app server client"); + assert_eq!(initialized.protocol_version, PROTOCOL_VERSION); +} + +fn run_request(message: &str) -> RunMessage { + RunMessage { + session: RunSessionSpec::Create { + session_name: "Example SDK Session".to_string(), + agent_type: "agentic".to_string(), + workspace_path: None, + }, + message: message.to_string(), + turn_id: None, + source: Some(AgentSubmissionSource::Cli), + } +} + fn spawn_server( runtime: BitfunAppRuntime, transport: impl agent_client_protocol::ConnectTo + 'static, @@ -1048,13 +1324,34 @@ fn spawn_server( }); } +fn spawn_server_with_frame_limit( + runtime: BitfunAppRuntime, + transport: impl agent_client_protocol::ConnectTo + 'static, + max_frame_bytes: u64, +) { + tokio::task::spawn_local(async move { + let _ = BitfunAppServer::new(runtime) + .with_max_frame_bytes(max_frame_bytes) + .serve(transport) + .await; + }); +} + +fn spawn_server_tracked( + runtime: BitfunAppRuntime, + transport: impl agent_client_protocol::ConnectTo + 'static, +) -> tokio::task::JoinHandle> { + tokio::task::spawn_local(async move { BitfunAppServer::new(runtime).serve(transport).await }) +} + #[tokio::test(flavor = "current_thread")] async fn lightweight_client_negotiates_with_the_production_server() { let local = LocalSet::new(); local .run_until(async { let (server_transport, client_transport) = transport::in_memory_channel_pair(); - spawn_server(build_app_runtime(), server_transport); + let (runtime, _permissions) = build_app_runtime_with_permissions(); + spawn_server(runtime, server_transport); let client = bitfun_app_server_client::connect(client_transport) .await @@ -1123,32 +1420,221 @@ async fn lightweight_client_negotiates_with_the_production_server() { ); assert!(!synchronized.agent_snapshot_available); - let error = client + client.shutdown().await; + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn initialize_reports_the_host_transport_frame_limit() { + const HOST_MAX_FRAME_BYTES: u64 = 256 * 1024; + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let (runtime, _permissions) = build_app_runtime_with_permissions(); + spawn_server_with_frame_limit(runtime, server_transport, HOST_MAX_FRAME_BYTES); + + let client = bitfun_app_server_client::connect(client_transport) + .await + .expect("lightweight client should connect"); + let initialized = client .initialize(InitializeRequest { - protocol_version: PROTOCOL_VERSION + 1, + protocol_version: PROTOCOL_VERSION, client: ClientInfo { - name: "bitfun-tui-test".to_string(), + name: "transport-limit-test".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), }, }) .await - .expect_err("a newer unsupported protocol must be rejected"); - assert_eq!(error.code, ErrorCode::InvalidParams); - let data: AppServerErrorData = serde_json::from_value( - error - .data - .expect("protocol rejection should carry stable data"), - ) - .expect("protocol rejection data should match the wire contract"); - assert_eq!(data.kind, AppServerErrorKind::InvalidRequest); - assert!(!data.retryable); - assert!(!data.outcome_unknown); - assert_eq!(data.capability.as_deref(), Some("app.initialize")); + .expect("initialize should report the Host limit"); + assert_eq!(initialized.limits.max_frame_bytes, HOST_MAX_FRAME_BYTES); client.shutdown().await; }) .await; } +#[tokio::test(flavor = "current_thread")] +async fn business_requests_require_successful_protocol_initialization() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + spawn_server(build_app_runtime(), server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let health = recv(cx.send_request(HealthRequest {})).await?; + assert_eq!(health.status, HealthStatus::Ready); + + let error = recv(cx.send_request(run_request("before initialize"))) + .await + .expect_err("business RPC must be rejected before app/initialize"); + assert_eq!(error.code, ErrorCode::InvalidParams); + + initialize_connection(&cx).await?; + let response = recv(cx.send_request(run_request("after initialize"))).await?; + assert_eq!(response.session_id, "example-session"); + assert_eq!(response.turn_id, "example-turn"); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn accepted_protocol_negotiation_cannot_be_revoked_by_repeated_initialize() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + spawn_server(build_app_runtime(), server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + initialize_connection(&cx).await?; + + let error = recv(cx.send_request(InitializeRequest { + protocol_version: MIN_PROTOCOL_VERSION - 1, + client: ClientInfo { + name: "duplicate-initialize-client".to_string(), + version: "3".to_string(), + }, + })) + .await + .expect_err("a repeated initialize must be rejected"); + assert_eq!(error.code, ErrorCode::InvalidParams); + + let response = + recv(cx.send_request(run_request("after repeated initialize"))).await?; + assert_eq!(response.session_id, "example-session"); + assert_eq!(response.turn_id, "example-turn"); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn pipelined_supported_initializations_accept_exactly_once() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + spawn_server(build_app_runtime(), server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let first = cx.send_request(InitializeRequest { + protocol_version: PROTOCOL_VERSION, + client: ClientInfo { + name: "first-v4-client".to_string(), + version: "4".to_string(), + }, + }); + let duplicate = cx.send_request(InitializeRequest { + protocol_version: PROTOCOL_VERSION, + client: ClientInfo { + name: "duplicate-v4-client".to_string(), + version: "4".to_string(), + }, + }); + + let initialized = recv(first).await?; + assert_eq!(initialized.protocol_version, PROTOCOL_VERSION); + let duplicate_error = recv(duplicate) + .await + .expect_err("only the first supported initialize may be accepted"); + assert_eq!(duplicate_error.code, ErrorCode::InvalidParams); + + let response = + recv(cx.send_request(run_request("after pipelined initialize"))).await?; + assert_eq!(response.session_id, "example-session"); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn incompatible_protocol_initialization_stays_fail_closed_until_client_disconnects() { + for protocol_version in [MIN_PROTOCOL_VERSION - 1, PROTOCOL_VERSION + 1] { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let server_task = spawn_server_tracked(build_app_runtime(), server_transport); + + let connection_result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let incompatible = cx.send_request(InitializeRequest { + protocol_version, + client: ClientInfo { + name: "incompatible-client".to_string(), + version: "3".to_string(), + }, + }); + let pipelined_renegotiation = cx.send_request(InitializeRequest { + protocol_version: PROTOCOL_VERSION, + client: ClientInfo { + name: "pipelined-v4-client".to_string(), + version: "4".to_string(), + }, + }); + let error = recv(incompatible) + .await + .expect_err("incompatible protocol must be rejected"); + assert_eq!(error.code, ErrorCode::InvalidParams); + let data: AppServerErrorData = serde_json::from_value( + error + .data + .expect("protocol rejection should carry stable data"), + ) + .expect("protocol rejection data should match the wire contract"); + assert_eq!(data.kind, AppServerErrorKind::InvalidRequest); + assert!(!data.retryable); + assert!(!data.outcome_unknown); + assert_eq!(data.capability.as_deref(), Some("app.initialize")); + + let renegotiation = recv(pipelined_renegotiation) + .await + .expect_err("a rejected connection must not be reopened"); + assert_eq!(renegotiation.code, ErrorCode::InvalidParams); + + let health_after_rejection = recv(cx.send_request(HealthRequest {})) + .await + .expect_err("health must report the terminal negotiation failure"); + assert_eq!(health_after_rejection.code, ErrorCode::InvalidParams); + + let business_after_rejection = + recv(cx.send_request(run_request("after rejected initialize"))) + .await + .expect_err("business RPC must remain fail closed"); + assert_eq!(business_after_rejection.code, ErrorCode::InvalidParams); + Ok(()) + }) + .await; + assert!(connection_result.is_ok(), "{connection_result:?}"); + let server_result = tokio::time::timeout(Duration::from_millis(500), server_task) + .await + .expect("server must stop after the rejected peer disconnects") + .expect("server task must not panic"); + assert!(server_result.is_ok(), "{server_result:?}"); + }) + .await; + } +} + #[tokio::test(flavor = "current_thread")] async fn session_control_methods_forward_exact_owner_dtos() { let local = LocalSet::new(); @@ -1161,6 +1647,7 @@ async fn session_control_methods_forward_exact_owner_dtos() { let result = AppClient .builder() .connect_with(client_transport, async |cx: ConnectionTo| { + initialize_connection(&cx).await?; let RenameSessionResponse {} = recv(cx.send_request(RenameSessionMessage( AgentSessionRenameRequest { workspace_path: "/repo".to_string(), @@ -1256,6 +1743,7 @@ async fn run_round_trips_through_create_and_submit() { let result = AppClient .builder() .connect_with(client_transport, async |cx: ConnectionTo| { + initialize_connection(&cx).await?; let response = recv(cx.send_request(RunMessage { session: RunSessionSpec::Create { session_name: "Example SDK Session".to_string(), @@ -1296,6 +1784,7 @@ async fn submit_dialog_turn_carries_agent_type_and_starts() { let result = AppClient .builder() .connect_with(client_transport, async |cx: ConnectionTo| { + initialize_connection(&cx).await?; let response = recv(cx.send_request(SubmitDialogTurnMessage( SubmitDialogTurnBody { session_id: "example-session".to_string(), @@ -1348,6 +1837,7 @@ async fn respond_permission_routes_to_the_permission_surface() { let result = AppClient .builder() .connect_with(client_transport, async |cx: ConnectionTo| { + initialize_connection(&cx).await?; let result = recv(cx.send_request(RespondPermissionMessage { request_id: "perm-1".to_string(), reply: bitfun_agent_runtime::sdk::PermissionReply::Once, @@ -1377,6 +1867,7 @@ async fn create_session_returns_provider_session_id() { let result = AppClient .builder() .connect_with(client_transport, async |cx: ConnectionTo| { + initialize_connection(&cx).await?; let response = recv(cx.send_request(CreateSessionMessage( AgentSessionCreateRequest { session_name: "direct create".to_string(), @@ -1415,6 +1906,7 @@ async fn submit_turn_surfaces_provider_result() { let result = AppClient .builder() .connect_with(client_transport, async |cx: ConnectionTo| { + initialize_connection(&cx).await?; let response = recv(cx.send_request(SubmitTurnMessage(AgentSubmissionRequest { session_id: "example-session".to_string(), @@ -1451,6 +1943,7 @@ async fn list_sessions_maps_missing_port_to_internal_error() { let result = AppClient .builder() .connect_with(client_transport, async |cx: ConnectionTo| { + initialize_connection(&cx).await?; let result = recv(cx.send_request(ListSessionsMessage( AgentSessionListRequest { workspace_path: ".".to_string(), @@ -1483,6 +1976,7 @@ async fn delete_session_maps_missing_port_to_internal_error() { let result = AppClient .builder() .connect_with(client_transport, async |cx: ConnectionTo| { + initialize_connection(&cx).await?; let result = recv(cx.send_request(DeleteSessionMessage( AgentSessionDeleteRequest { workspace_path: ".".to_string(), @@ -1516,6 +2010,7 @@ async fn cancel_turn_maps_missing_port_to_internal_error() { let result = AppClient .builder() .connect_with(client_transport, async |cx: ConnectionTo| { + initialize_connection(&cx).await?; let result = recv(cx.send_request(CancelTurnMessage( AgentTurnCancellationRequest { session_id: "example-session".to_string(), @@ -1552,6 +2047,7 @@ async fn unknown_agent_method_returns_method_not_found() { let result = AppClient .builder() .connect_with(client_transport, async |cx: ConnectionTo| { + initialize_connection(&cx).await?; let response = recv(cx.send_request(UnknownAgentRequest)).await; assert!( response.is_err(), @@ -1569,7 +2065,7 @@ async fn unknown_agent_method_returns_method_not_found() { /// `AgentEventSource` to the client as `agent/event` notifications, not leave /// the client to subscribe to the runtime queue directly. #[tokio::test(flavor = "current_thread")] -async fn runtime_events_are_forwarded_as_agent_event_notifications() { +async fn successful_initialize_installs_event_receivers_without_replaying_preinit_events() { let local = LocalSet::new(); local .run_until(async { @@ -1577,57 +2073,319 @@ async fn runtime_events_are_forwarded_as_agent_event_notifications() { let (runtime, event_queue) = build_app_runtime_with_queue(); spawn_server(runtime, server_transport); - let received: Arc>> = - Arc::new(Mutex::new(Vec::new())); - let received_for_client = received.clone(); + let (received_tx, mut received_rx) = + tokio::sync::mpsc::unbounded_channel::(); let queue_for_client = event_queue.clone(); let result = AppClient .builder() .on_receive_notification( - { - let received = received_for_client.clone(); - async move |notification: AgentEventNotification, - _cx: ConnectionTo| { - received.lock().unwrap().push(notification); - Ok(()) - } + async move |notification: AgentEventNotification, + _cx: ConnectionTo| { + received_tx + .send(notification) + .map_err(|_| agent_client_protocol::Error::internal_error())?; + Ok(()) }, agent_client_protocol::on_receive_notification!(), ) - .connect_with(client_transport, async |_cx: ConnectionTo| { - // Let the server's forwarder subscribe before publishing. - tokio::time::sleep(Duration::from_millis(50)).await; - queue_for_client - .enqueue( - AgenticEvent::SessionStateChanged { - session_id: "s1".to_string(), - new_state: "ready".to_string(), - }, - None, + .connect_with( + client_transport, + async move |cx: ConnectionTo| { + // This event predates successful negotiation and must never + // be replayed after the connection accepts v4. + queue_for_client + .enqueue( + serde_json::from_value(serde_json::json!({ + "type": "ToolEvent", + "session_id": "s1", + "turn_id": "t1", + "round_id": "r1", + "tool_event": { + "event_type": "UserInputRequested", + "tool_id": "question-1", + "tool_name": "AskUserQuestion", + "registration_sequence": 1, + "params": { "questions": [] } + } + })) + .expect("user-input event should match the stable event contract"), + None, + ) + .await + .expect("pre-initialize event should enqueue"); + + initialize_connection(&cx).await?; + queue_for_client + .enqueue( + AgenticEvent::SessionStateChanged { + session_id: "s1".to_string(), + new_state: "ready".to_string(), + }, + None, + ) + .await + .expect("event should enqueue"); + + let received = tokio::time::timeout( + Duration::from_millis(500), + received_rx.recv(), ) .await - .expect("event should enqueue"); - // Allow time for the server forwarder + client dispatch. - tokio::time::sleep(Duration::from_millis(200)).await; + .expect("post-initialize event should arrive without a subscription delay") + .expect("notification stream should stay open"); + assert_eq!(received.cursor.sequence, 1); + assert!(received.cursor.connection_id.starts_with("app-server-")); + assert!(matches!( + &received.event.event, + AgenticEvent::SessionStateChanged { session_id, new_state } + if session_id == "s1" && new_state == "ready" + )); + assert!( + received_rx.try_recv().is_err(), + "the pre-initialize UserInputRequested event must not be replayed" + ); + Ok(()) + }, + ) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn permission_sync_snapshot_watermark_tracks_forwarded_permission_events() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let (runtime, permissions) = build_app_runtime_with_permissions(); + spawn_server(runtime, server_transport); + + let (received_tx, mut received_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let permissions_for_client = permissions.clone(); + let result = AppClient + .builder() + .on_receive_notification( + async move |notification: PermissionEventNotification, + _cx: ConnectionTo| { + received_tx + .send(notification) + .map_err(|_| agent_client_protocol::Error::internal_error())?; + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) + .connect_with( + client_transport, + async move |cx: ConnectionTo| { + initialize_connection(&cx).await?; + let request = permission_request("permission-sync"); + let _pending = permissions_for_client + .register(request.clone()) + .await + .expect("permission should register"); + let asked = + tokio::time::timeout(Duration::from_millis(500), received_rx.recv()) + .await + .expect("asked event should be forwarded") + .expect("permission notification stream should stay open"); + assert!(matches!( + &asked.event, + bitfun_agent_runtime::sdk::PermissionRequestEvent::Asked { + request: asked_request + } if asked_request == &request + )); + + let synchronized = recv(cx.send_request(SyncEventsRequest { + streams: vec![EventStream::Permission], + })) + .await?; + assert_eq!(synchronized.pending_permissions, vec![request.clone()]); + assert_eq!(synchronized.cursors.len(), 1); + assert_eq!( + synchronized.cursors[0].sequence, asked.cursor.sequence, + "snapshot watermark should include the already-forwarded Asked event" + ); + + permissions_for_client + .reply( + &request.request_id, + PermissionReply::Once, + PermissionReplySource::User, + ) + .await + .expect("permission should resolve"); + let replied = + tokio::time::timeout(Duration::from_millis(500), received_rx.recv()) + .await + .expect("reply event should be forwarded") + .expect("permission notification stream should stay open"); + assert!(matches!( + &replied.event, + bitfun_agent_runtime::sdk::PermissionRequestEvent::Replied { + request_id, + .. + } if request_id == &request.request_id + )); + + let synchronized = recv(cx.send_request(SyncEventsRequest { + streams: vec![EventStream::Permission], + })) + .await?; + assert!(synchronized.pending_permissions.is_empty()); + assert_eq!( + synchronized.cursors[0].sequence, replied.cursor.sequence, + "snapshot watermark should include the already-forwarded Replied event" + ); + Ok(()) + }, + ) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn permission_sync_replays_the_same_request_after_a_blocked_audit_snapshot() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let gate = Arc::new(PermissionAuditGate::default()); + let store = Arc::new(TestPermissionStore { + audit_gate: Some(gate.clone()), + }); + let (runtime, permissions) = build_app_runtime_with_permission_store(store); + spawn_server(runtime, server_transport); + + let (received_tx, mut received_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let result = AppClient + .builder() + .on_receive_notification( + async move |notification: PermissionEventNotification, + _cx: ConnectionTo| { + received_tx + .send(notification) + .map_err(|_| agent_client_protocol::Error::internal_error())?; + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) + .connect_with( + client_transport, + async move |cx: ConnectionTo| { + let initialized = recv(cx.send_request(InitializeRequest { + protocol_version: PROTOCOL_VERSION, + client: ClientInfo { + name: "bitfun-app-server-test".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + })) + .await?; + let permission = initialized + .capabilities + .iter() + .find(|capability| capability.id == "permission") + .expect("permission capability should be declared"); + assert!(matches!( + permission.availability, + CapabilityAvailability::Available + )); + + let request = permission_request("permission-audit-window"); + let permissions_for_registration = permissions.clone(); + let request_for_registration = request.clone(); + let registration = tokio::task::spawn_local(async move { + permissions_for_registration + .register(request_for_registration) + .await + }); + + gate.started.notified().await; + let synchronized = recv(cx.send_request(SyncEventsRequest { + streams: vec![EventStream::Permission], + })) + .await?; + assert_eq!(synchronized.pending_permissions, vec![request.clone()]); + assert_eq!(synchronized.cursors.len(), 1); + assert_eq!(synchronized.cursors[0].sequence, 0); + assert!( + received_rx.try_recv().is_err(), + "Asked must remain blocked behind the permission audit" + ); + + gate.release.notify_one(); + let _pending = registration + .await + .expect("permission registration task should finish") + .expect("permission registration should succeed"); + let asked = received_rx + .recv() + .await + .expect("permission notification stream should stay open"); + let asked_request = match asked.event { + bitfun_agent_runtime::sdk::PermissionRequestEvent::Asked { + request, + } => request, + other => panic!("expected Asked, got {other:?}"), + }; + assert_eq!(asked_request.request_id, request.request_id); + assert!(asked.cursor.sequence > synchronized.cursors[0].sequence); + Ok(()) + }, + ) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn event_sync_fails_closed_without_permission_snapshot_owner() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + spawn_server(build_app_runtime(), server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let initialized = recv(cx.send_request(InitializeRequest { + protocol_version: PROTOCOL_VERSION, + client: ClientInfo { + name: "bitfun-app-server-test".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + })) + .await?; + let permission = initialized + .capabilities + .iter() + .find(|capability| capability.id == "permission") + .expect("permission capability should be declared"); + assert!(matches!( + permission.availability, + CapabilityAvailability::Unavailable { .. } + )); + let error = recv(cx.send_request(SyncEventsRequest { + streams: vec![EventStream::Permission], + })) + .await + .expect_err("missing permission owner must not look like an empty snapshot"); + assert_eq!( + error.code, + (AppServerErrorKind::Internal.json_rpc_code() as i32).into() + ); Ok(()) }) .await; assert!(result.is_ok(), "{result:?}"); - - let received = received.lock().unwrap().clone(); - assert_eq!( - received.len(), - 1, - "should receive exactly one authoritative Agent event, got {received:?}" - ); - assert_eq!(received[0].cursor.sequence, 1); - assert!(received[0].cursor.connection_id.starts_with("app-server-")); - assert!(matches!( - &received[0].event.event, - AgenticEvent::SessionStateChanged { session_id, new_state } - if session_id == "s1" && new_state == "ready" - )); }) .await; } diff --git a/src/crates/interfaces/sdk-host/src/host.rs b/src/crates/interfaces/sdk-host/src/host.rs index dbe8b24d28..cb886ad015 100644 --- a/src/crates/interfaces/sdk-host/src/host.rs +++ b/src/crates/interfaces/sdk-host/src/host.rs @@ -2207,6 +2207,7 @@ fn runtime_error_kind(error: &RuntimeError) -> &'static str { | RuntimeError::MissingSessionLineagePort | RuntimeError::MissingSessionManagementPort | RuntimeError::MissingSessionRestorePort + | RuntimeError::MissingContextReloadPort | RuntimeError::MissingLocalCommandTurnPort | RuntimeError::MissingWorkspaceReferencePort | RuntimeError::MissingSessionTranscriptReader @@ -2263,4 +2264,15 @@ mod runtime_error_tests { ); assert_eq!(runtime_error_kind(&error), "capability_unavailable"); } + + #[test] + fn missing_context_reload_port_uses_capability_unavailable_contract() { + let error = RuntimeError::MissingContextReloadPort; + + assert_eq!( + runtime_error_facts(&error), + (ErrorCode::CapabilityUnavailable, false, None) + ); + assert_eq!(runtime_error_kind(&error), "capability_unavailable"); + } } diff --git a/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs b/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs index 747a0106c4..e6a4364d84 100644 --- a/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs +++ b/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs @@ -819,7 +819,7 @@ async fn query_on_created_transient_session_preserves_connection_lifetime() { } #[tokio::test] -async fn dialog_session_identity_mismatch_releases_the_requested_session_reservation() { +async fn dialog_session_identity_mismatch_is_outcome_unknown_and_releases_the_reservation() { let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); let owner = Arc::new(FakeOwner::mismatched_dialog(queue.clone())); let runtime = AgentRuntimeBuilder::new() @@ -861,7 +861,9 @@ async fn dialog_session_identity_mismatch_releases_the_requested_session_reserva .await; let rejected = output.recv().await.unwrap(); assert_eq!(rejected["id"], "query-mismatch"); - assert_eq!(rejected["error"]["data"]["code"], "internal"); + assert_eq!(rejected["error"]["data"]["code"], "action_required"); + assert_eq!(rejected["error"]["data"]["retryable"], false); + assert!(rejected["error"]["data"]["recovery"].is_null()); host.handle_request(request(serde_json::json!({ "jsonrpc": "2.0", diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index e470b36bfd..640ad9d1c0 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -65,12 +65,14 @@ pub use relay_client::{ ensure_rustls_crypto_provider, ConnectionState, RelayClient, RelayEvent, RelayMessage, }; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use tokio::io::{AsyncReadExt, AsyncSeekExt}; +pub const REMOTE_CAPABILITY_ANSWER_QUESTION_IDENTITY_V1: &str = "answer_question_identity_v1"; + fn deserialize_present_nullable<'de, D, T>(deserializer: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, @@ -899,6 +901,7 @@ pub fn remote_workspace_info_response(workspace: Option) - assistant_id: workspace.assistant_id, remote_connection_id: workspace.remote_connection_id, remote_ssh_host: workspace.remote_ssh_host, + capabilities: vec![REMOTE_CAPABILITY_ANSWER_QUESTION_IDENTITY_V1.to_string()], }, None => RemoteResponse::WorkspaceInfo { has_workspace: false, @@ -909,6 +912,7 @@ pub fn remote_workspace_info_response(workspace: Option) - assistant_id: None, remote_connection_id: None, remote_ssh_host: None, + capabilities: vec![REMOTE_CAPABILITY_ANSWER_QUESTION_IDENTITY_V1.to_string()], }, } } @@ -1072,6 +1076,7 @@ pub fn remote_initial_sync_response( sessions, has_more_sessions, authenticated_user_id, + capabilities: vec![REMOTE_CAPABILITY_ANSWER_QUESTION_IDENTITY_V1.to_string()], } } @@ -1526,7 +1531,11 @@ pub trait RemoteInteractionRuntimeHost: Send + Sync { &self, mode: RemotePermissionMode, ) -> Result; - fn answer_question(&self, tool_id: &str, answers: serde_json::Value) -> Result<(), String>; + fn answer_question( + &self, + identity: &RemoteUserInputIdentity, + answers: serde_json::Value, + ) -> Result<(), String>; } pub async fn handle_remote_interaction_command( @@ -1571,8 +1580,32 @@ where host.cancel_tool(tool_id, cancel_reason).await, ) } - RemoteCommand::AnswerQuestion { tool_id, answers } => { - remote_answer_question_response(host.answer_question(tool_id, answers.clone())) + RemoteCommand::AnswerQuestion { + session_id, + turn_id, + tool_id, + registration_sequence, + answers, + } => { + let (Some(session_id), Some(turn_id), Some(registration_sequence)) = + (session_id, turn_id, registration_sequence) + else { + return RemoteResponse::UpgradeRequired { + code: "upgrade_required".to_string(), + capability: REMOTE_CAPABILITY_ANSWER_QUESTION_IDENTITY_V1.to_string(), + message: "This version cannot safely answer the question because it did not send the full registration identity.".to_string(), + recovery: "Upgrade the mobile client, reconnect to Desktop, and answer the question again.".to_string(), + }; + }; + remote_answer_question_response(host.answer_question( + &RemoteUserInputIdentity { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + tool_id: tool_id.clone(), + registration_sequence: *registration_sequence, + }, + answers.clone(), + )) } _ => RemoteResponse::Error { message: "Unknown execution command".into(), @@ -2027,6 +2060,7 @@ pub fn build_remote_chat_messages(turns: Vec) -> Vec, #[serde(skip_serializing_if = "Option::is_none")] pub tool_input: Option, + /// Authoritative identity required to answer an interactive tool call. + /// `Started` alone never populates this field. + #[serde(skip_serializing_if = "Option::is_none")] + pub user_input_identity: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RemoteUserInputIdentity { + pub session_id: String, + pub turn_id: String, + pub tool_id: String, + pub registration_sequence: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -2222,7 +2268,13 @@ pub enum RemoteCommand { mode: RemotePermissionMode, }, AnswerQuestion { + #[serde(default)] + session_id: Option, + #[serde(default)] + turn_id: Option, tool_id: String, + #[serde(default)] + registration_sequence: Option, answers: serde_json::Value, }, PollSession { @@ -2335,6 +2387,8 @@ pub enum RemoteResponse { remote_connection_id: Option, #[serde(skip_serializing_if = "Option::is_none")] remote_ssh_host: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + capabilities: Vec, }, RecentWorkspaces { workspaces: Vec, @@ -2412,6 +2466,8 @@ pub enum RemoteResponse { has_more_sessions: bool, #[serde(skip_serializing_if = "Option::is_none")] authenticated_user_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + capabilities: Vec, }, SessionPoll { version: u64, @@ -2430,6 +2486,13 @@ pub enum RemoteResponse { model_catalog: Box>, }, AnswerAccepted, + #[serde(rename = "error", skip_deserializing)] + UpgradeRequired { + code: String, + capability: String, + message: String, + recovery: String, + }, InteractionAccepted { action: String, target_id: String, @@ -2686,6 +2749,7 @@ struct TrackerState { active_tools: Vec, round_index: usize, active_items: Vec, + settled_user_inputs: HashSet, persistence_dirty: bool, linked_subagent_sessions: HashMap, } @@ -2701,6 +2765,10 @@ pub enum TrackerEvent { tool_name: String, params: Option, }, + UserInputRequested { + identity: RemoteUserInputIdentity, + params: serde_json::Value, + }, ToolCompleted { tool_id: String, tool_name: String, @@ -2743,6 +2811,7 @@ impl RemoteSessionStateTracker { active_tools: Vec::new(), round_index: 0, active_items: Vec::new(), + settled_user_inputs: HashSet::new(), persistence_dirty: true, linked_subagent_sessions: HashMap::new(), }), @@ -2828,6 +2897,39 @@ impl RemoteSessionStateTracker { self.bump_version(); } + pub fn hydrate_active_turn(&self, seed: RemoteActiveTurnSeed) { + let mut state = self.state.write().unwrap(); + let mut changed = false; + if state + .turn_id + .as_ref() + .is_some_and(|turn_id| turn_id != &seed.turn_id) + { + return; + } + if state.turn_id.is_none() { + state.turn_id = Some(seed.turn_id.clone()); + state.turn_status = "active".to_string(); + state.session_state = "running".to_string(); + changed = true; + } + + for pending in seed.pending_user_inputs { + if pending.identity.session_id != self.target_session_id + || pending.identity.turn_id != seed.turn_id + || state.settled_user_inputs.contains(&pending.identity) + { + continue; + } + Self::upsert_pending_user_input(&mut state, pending.identity, pending.params, false); + changed = true; + } + drop(state); + if changed { + self.bump_version(); + } + } + pub fn sync_pending_permission( &self, tool_id: String, @@ -2872,6 +2974,7 @@ impl RemoteSessionStateTracker { state.accumulated_thinking.clear(); state.active_tools.clear(); state.active_items.clear(); + state.settled_user_inputs.clear(); } } @@ -2891,6 +2994,7 @@ impl RemoteSessionStateTracker { state.accumulated_thinking.clear(); state.active_tools.clear(); state.active_items.clear(); + state.settled_user_inputs.clear(); state.session_state = "idle".to_string(); state.persistence_dirty = true; drop(state); @@ -2931,11 +3035,10 @@ impl RemoteSessionStateTracker { let allow_name_fallback = tool_id.is_empty() && !tool_name.is_empty(); let subagent_marker = if is_subagent { Some(true) } else { None }; - if let Some(tool) = - state.active_tools.iter_mut().rev().find(|tool| { - tool.id == resolved_id || (allow_name_fallback && tool.name == tool_name) - }) - { + if let Some(tool) = state.active_tools.iter_mut().rev().find(|tool| { + tool.user_input_identity.is_none() + && (tool.id == resolved_id || (allow_name_fallback && tool.name == tool_name)) + }) { tool.status = status.to_string(); if input_preview.is_some() { tool.input_preview = input_preview.clone(); @@ -2957,6 +3060,7 @@ impl RemoteSessionStateTracker { ), input_preview, tool_input, + user_input_identity: None, }; state.active_tools.push(tool_status.clone()); state.active_items.push(ChatMessageItem { @@ -2971,7 +3075,9 @@ impl RemoteSessionStateTracker { if let Some(item) = state.active_items.iter_mut().rev().find(|item| { item.item_type == "tool" && item.tool.as_ref().is_some_and(|tool| { - tool.id == resolved_id || (allow_name_fallback && tool.name == tool_name) + tool.user_input_identity.is_none() + && (tool.id == resolved_id + || (allow_name_fallback && tool.name == tool_name)) }) }) { if let Some(tool) = item.tool.as_mut() { @@ -2986,6 +3092,86 @@ impl RemoteSessionStateTracker { } } + fn upsert_pending_user_input( + state: &mut TrackerState, + identity: RemoteUserInputIdentity, + params: serde_json::Value, + is_subagent: bool, + ) { + let input_preview = make_slim_tool_params(¶ms); + let subagent_marker = if is_subagent { Some(true) } else { None }; + let tool_index = state + .active_tools + .iter() + .rposition(|tool| tool.user_input_identity.as_ref() == Some(&identity)) + .or_else(|| { + state.active_tools.iter().rposition(|tool| { + tool.id == identity.tool_id && tool.user_input_identity.is_none() + }) + }); + + if let Some(index) = tool_index { + let tool = &mut state.active_tools[index]; + tool.name = "AskUserQuestion".to_string(); + tool.status = "waiting_for_user".to_string(); + tool.input_preview = input_preview.clone(); + tool.tool_input = Some(params.clone()); + tool.user_input_identity = Some(identity.clone()); + } else { + state.active_tools.push(RemoteToolStatus { + id: identity.tool_id.clone(), + name: "AskUserQuestion".to_string(), + status: "waiting_for_user".to_string(), + duration_ms: None, + start_ms: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + ), + input_preview: input_preview.clone(), + tool_input: Some(params.clone()), + user_input_identity: Some(identity.clone()), + }); + } + + let item_index = state + .active_items + .iter() + .rposition(|item| { + item.tool + .as_ref() + .is_some_and(|tool| tool.user_input_identity.as_ref() == Some(&identity)) + }) + .or_else(|| { + state.active_items.iter().rposition(|item| { + item.tool.as_ref().is_some_and(|tool| { + tool.id == identity.tool_id && tool.user_input_identity.is_none() + }) + }) + }); + if let Some(tool) = item_index.and_then(|index| state.active_items[index].tool.as_mut()) { + tool.name = "AskUserQuestion".to_string(); + tool.status = "waiting_for_user".to_string(); + tool.input_preview = input_preview; + tool.tool_input = Some(params); + tool.user_input_identity = Some(identity); + } else { + let projected_tool = state + .active_tools + .iter() + .rev() + .find(|tool| tool.user_input_identity.as_ref() == Some(&identity)) + .cloned(); + state.active_items.push(ChatMessageItem { + item_type: "tool".to_string(), + content: None, + tool: projected_tool, + is_subagent: subagent_marker, + }); + } + } + pub fn handle_agentic_event(&self, event: &AgenticEvent) { use bitfun_events::AgenticEvent as AE; @@ -3088,7 +3274,12 @@ impl RemoteSessionStateTracker { .send(TrackerEvent::ThinkingChunk(content.clone())); } } - AE::ToolEvent { tool_event, .. } => { + AE::ToolEvent { + session_id, + turn_id, + tool_event, + .. + } => { let tool_id = tool_event.tool_id().to_string(); let tool_name = tool_event.effective_tool_name().to_string(); let effective_params = match tool_event { @@ -3163,6 +3354,72 @@ impl RemoteSessionStateTracker { params, }); } + "UserInputRequested" => { + if let bitfun_events::ToolEventData::UserInputRequested { + registration_sequence, + params, + .. + } = tool_event + { + let identity = RemoteUserInputIdentity { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + tool_id: tool_id.clone(), + registration_sequence: *registration_sequence, + }; + if state.settled_user_inputs.contains(&identity) { + drop(state); + return; + } + Self::upsert_pending_user_input( + &mut state, + identity.clone(), + params.clone(), + is_subagent, + ); + pending_tool_event = Some(TrackerEvent::UserInputRequested { + identity, + params: params.clone(), + }); + } + } + "UserInputResolved" => { + if let bitfun_events::ToolEventData::UserInputResolved { + registration_sequence, + .. + } = tool_event + { + let identity = RemoteUserInputIdentity { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + tool_id: tool_id.clone(), + registration_sequence: *registration_sequence, + }; + state.settled_user_inputs.insert(identity.clone()); + if let Some(tool) = + state.active_tools.iter_mut().rev().find(|tool| { + tool.id == tool_id + && tool.status == "waiting_for_user" + && tool.user_input_identity.as_ref() == Some(&identity) + }) + { + tool.status = "completed".to_string(); + } + if let Some(tool) = state + .active_items + .iter_mut() + .rev() + .filter_map(|item| item.tool.as_mut()) + .find(|tool| { + tool.id == tool_id + && tool.status == "waiting_for_user" + && tool.user_input_identity.as_ref() == Some(&identity) + }) + { + tool.status = "completed".to_string(); + } + } + } "Confirmed" => { Self::upsert_active_tool( &mut state, @@ -3287,6 +3544,7 @@ impl RemoteSessionStateTracker { state.accumulated_thinking.clear(); state.active_tools.clear(); state.active_items.clear(); + state.settled_user_inputs.clear(); state.round_index = 0; state.session_state = "running".to_string(); state.persistence_dirty = true; @@ -3361,12 +3619,43 @@ impl RemoteSessionStateTracker { pub trait RemoteSessionTrackerHost { fn subscribe_tracker(&self, session_id: &str, tracker: Arc); fn unsubscribe_tracker(&self, session_id: &str); - fn active_turn_id(&self, session_id: &str) -> Option; + fn active_turn_seed(&self, session_id: &str) -> Option; +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RemotePendingUserInputSeed { + pub identity: RemoteUserInputIdentity, + pub params: serde_json::Value, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RemoteActiveTurnSeed { + pub turn_id: String, + pub pending_user_inputs: Vec, } #[derive(Default)] pub struct RemoteSessionTrackerRegistry { - state_trackers: RwLock>>, + state_trackers: RwLock>>, +} + +enum RemoteSessionTrackerEntryState { + Initializing, + Ready(Arc), + Removing, + Removed, +} + +struct RemoteSessionTrackerEntry { + state: Mutex, +} + +impl RemoteSessionTrackerEntry { + fn initializing() -> Self { + Self { + state: Mutex::new(RemoteSessionTrackerEntryState::Initializing), + } + } } impl RemoteSessionTrackerRegistry { @@ -3379,30 +3668,66 @@ impl RemoteSessionTrackerRegistry { session_id: &str, host: &H, ) -> Arc { - if let Some(tracker) = self.get_tracker(session_id) { - return tracker; - } - - let tracker = { + loop { + let candidate = Arc::new(RemoteSessionTrackerEntry::initializing()); + let mut candidate_state = candidate + .state + .lock() + .expect("remote tracker entry lock poisoned"); let mut trackers = self.state_trackers.write().unwrap(); - if let Some(tracker) = trackers.get(session_id) { - return tracker.clone(); + if let Some(existing) = trackers.get(session_id).cloned() { + drop(trackers); + drop(candidate_state); + let state = existing + .state + .lock() + .expect("remote tracker entry lock poisoned"); + match &*state { + RemoteSessionTrackerEntryState::Ready(tracker) => return tracker.clone(), + RemoteSessionTrackerEntryState::Removed => continue, + RemoteSessionTrackerEntryState::Removing => { + unreachable!("remover holds the entry lock until unsubscribe finishes") + } + RemoteSessionTrackerEntryState::Initializing => { + unreachable!("initializer holds the entry lock until ready") + } + } } + + trackers.insert(session_id.to_string(), candidate.clone()); + drop(trackers); let tracker = Arc::new(RemoteSessionStateTracker::new(session_id.to_string())); - trackers.insert(session_id.to_string(), tracker.clone()); - tracker - }; + host.subscribe_tracker(session_id, tracker.clone()); + if let Some(seed) = host.active_turn_seed(session_id) { + tracker.hydrate_active_turn(seed); + } + *candidate_state = RemoteSessionTrackerEntryState::Ready(tracker.clone()); - host.subscribe_tracker(session_id, tracker.clone()); - if let Some(active_turn_id) = host.active_turn_id(session_id) { - tracker.initialize_active_turn(active_turn_id); + return tracker; } - - tracker } pub fn get_tracker(&self, session_id: &str) -> Option> { - self.state_trackers.read().unwrap().get(session_id).cloned() + let entry = self + .state_trackers + .read() + .unwrap() + .get(session_id) + .cloned()?; + let state = entry + .state + .lock() + .expect("remote tracker entry lock poisoned"); + match &*state { + RemoteSessionTrackerEntryState::Ready(tracker) => Some(tracker.clone()), + RemoteSessionTrackerEntryState::Removed => None, + RemoteSessionTrackerEntryState::Removing => { + unreachable!("remover holds the entry lock until unsubscribe finishes") + } + RemoteSessionTrackerEntryState::Initializing => { + unreachable!("initializer holds the entry lock until ready") + } + } } pub fn remove_tracker_with_host( @@ -3410,11 +3735,43 @@ impl RemoteSessionTrackerRegistry { session_id: &str, host: &H, ) -> Option> { - let removed = self.state_trackers.write().unwrap().remove(session_id); - if removed.is_some() { - host.unsubscribe_tracker(session_id); + let entry = self + .state_trackers + .read() + .unwrap() + .get(session_id) + .cloned()?; + let mut state = entry + .state + .lock() + .expect("remote tracker entry lock poisoned"); + let removed = match &*state { + RemoteSessionTrackerEntryState::Ready(tracker) => tracker.clone(), + RemoteSessionTrackerEntryState::Removed => return None, + RemoteSessionTrackerEntryState::Removing => { + unreachable!("remover holds the entry lock until unsubscribe finishes") + } + RemoteSessionTrackerEntryState::Initializing => { + unreachable!("initializer holds the entry lock until ready") + } + }; + *state = RemoteSessionTrackerEntryState::Removing; + + // Keep this generation published and hold its entry lock until the Host + // finishes unsubscribing. A concurrent ensure can then observe only the + // old generation (and wait), never insert a replacement whose fixed + // Host subscription key could be removed by this delayed unsubscribe. + host.unsubscribe_tracker(session_id); + let mut trackers = self.state_trackers.write().unwrap(); + if trackers + .get(session_id) + .is_some_and(|current| Arc::ptr_eq(current, &entry)) + { + trackers.remove(session_id); } - removed + drop(trackers); + *state = RemoteSessionTrackerEntryState::Removed; + Some(removed) } } @@ -3616,6 +3973,7 @@ mod tests { assistant_id: None, remote_connection_id: None, remote_ssh_host: None, + capabilities: vec![REMOTE_CAPABILITY_ANSWER_QUESTION_IDENTITY_V1.to_string()], } ); @@ -4090,7 +4448,7 @@ mod tests { fn answer_question( &self, - _tool_id: &str, + _identity: &RemoteUserInputIdentity, _answers: serde_json::Value, ) -> Result<(), String> { Ok(()) diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/state.rs b/src/crates/services/services-integrations/src/remote_connect/bot/state.rs index 8e98df3351..886cbc0842 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/state.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/state.rs @@ -1,3 +1,4 @@ +use crate::remote_connect::RemoteUserInputIdentity; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::future::Future; @@ -351,7 +352,7 @@ pub enum PendingAction { options: Vec<(String, String)>, }, AskUserQuestion { - tool_id: String, + identity: RemoteUserInputIdentity, questions: Vec, current_index: usize, answers: Vec, diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 63095104b9..92aca6ca34 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -14,15 +14,15 @@ use bitfun_services_integrations::remote_connect::{ build_remote_chat_messages, build_remote_image_attachment, build_remote_image_contexts, build_remote_image_submission_request, build_remote_model_catalog, build_remote_session_create_request, build_remote_submission_request, cancel_remote_task, - handle_remote_command, handle_remote_workspace_file_command, make_slim_tool_params, - normalize_remote_model_selection, normalize_remote_session_model_id, project_remote_chat_user, - read_remote_workspace_file, read_remote_workspace_file_chunk, read_remote_workspace_file_info, - remote_answer_question_response, remote_assistant_list_response, - remote_assistant_updated_response, remote_dialog_submit_outcome_from_scheduler, - remote_dialog_submit_response, remote_file_chunk_response, remote_file_content_response, - remote_file_display_name, remote_file_info_response, remote_initial_sync_response, - remote_interaction_accepted_response, remote_messages_response, - remote_model_catalog_poll_delta, remote_model_selection_needs_config, + handle_remote_command, handle_remote_interaction_command, handle_remote_workspace_file_command, + make_slim_tool_params, normalize_remote_model_selection, normalize_remote_session_model_id, + project_remote_chat_user, read_remote_workspace_file, read_remote_workspace_file_chunk, + read_remote_workspace_file_info, remote_answer_question_response, + remote_assistant_list_response, remote_assistant_updated_response, + remote_dialog_submit_outcome_from_scheduler, remote_dialog_submit_response, + remote_file_chunk_response, remote_file_content_response, remote_file_display_name, + remote_file_info_response, remote_initial_sync_response, remote_interaction_accepted_response, + remote_messages_response, remote_model_catalog_poll_delta, remote_model_selection_needs_config, remote_no_change_poll_response, remote_persisted_poll_response, remote_recent_workspaces_response, remote_session_created_response, remote_session_deleted_response, remote_session_info, remote_session_list_response, @@ -33,24 +33,30 @@ use bitfun_services_integrations::remote_connect::{ resolve_remote_workspace_path, should_send_remote_model_catalog, submit_remote_dialog, ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, ChatMessageItem, DeviceIdentity, ImageAttachment, KeyPair, PairingChallenge, PairingProtocol, PairingResponse, PairingState, - QrGenerator, QrPayload, RelayMessage, RemoteAssistantWorkspaceFacts, RemoteCancelDecision, - RemoteCancelRuntimeHost, RemoteCancelTaskRequest, RemoteChatHistoryRound, + QrGenerator, QrPayload, RelayMessage, RemoteActiveTurnSeed, RemoteAssistantWorkspaceFacts, + RemoteCancelDecision, RemoteCancelRuntimeHost, RemoteCancelTaskRequest, RemoteChatHistoryRound, RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSubmissionPolicy, RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, RemoteImageContext, RemoteImageContextAdapter, - RemoteModelCapabilityFact, RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelConfig, - RemoteModelFacts, RemoteRecentWorkspaceFacts, RemoteResponse, RemoteSessionMetadata, + RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, RemoteModelCatalog, + RemoteModelCatalogFacts, RemoteModelConfig, RemoteModelFacts, RemotePendingUserInputSeed, + RemotePermissionMode, RemoteRecentWorkspaceFacts, RemoteResponse, RemoteSessionMetadata, RemoteSessionModelSelection, RemoteSessionStateTracker, RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, RemoteSessionWorkspaceIdentity, RemoteTerminalPrewarmRequest, - RemoteToolStatus, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, RemoteWorkspaceFileContent, - RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, - RemoteWorkspaceUpdate, TrackerEvent, REMOTE_FILE_MAX_CHUNK_BYTES, REMOTE_FILE_MAX_READ_BYTES, + RemoteToolStatus, RemoteUserInputIdentity, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, + RemoteWorkspaceFileContent, RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, + RemoteWorkspaceKind, RemoteWorkspaceUpdate, TrackerEvent, REMOTE_FILE_MAX_CHUNK_BYTES, + REMOTE_FILE_MAX_READ_BYTES, }; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + mpsc, Arc, Barrier, Mutex, +}; +use std::time::Duration; #[tokio::test] async fn remote_connect_pairing_primitives_live_in_services_owner() { @@ -857,6 +863,7 @@ impl RemoteCommandRuntimeHost for RecordingCommandHost { assistant_id: None, remote_connection_id: None, remote_ssh_host: None, + capabilities: vec!["answer_question_identity_v1".to_string()], } } @@ -1660,6 +1667,85 @@ fn remote_connect_execution_response_helpers_preserve_wire_shape() { ); } +#[test] +fn remote_connect_legacy_answer_question_payload_decodes_for_safe_upgrade_rejection() { + let decoded = serde_json::from_value::(serde_json::json!({ + "cmd": "answer_question", + "tool_id": "question-1", + "answers": { "0": "yes" } + })); + + assert!( + decoded.is_ok(), + "legacy payload must reach compatibility handling" + ); +} + +#[derive(Default)] +struct RecordingInteractionHost { + answer_calls: AtomicUsize, +} + +#[async_trait::async_trait] +impl RemoteInteractionRuntimeHost for RecordingInteractionHost { + async fn cancel_tool(&self, _tool_id: &str, _reason: String) -> Result<(), String> { + Ok(()) + } + + async fn confirm_tool(&self, _tool_id: &str) -> Result<(), String> { + Ok(()) + } + + async fn reject_tool(&self, _tool_id: &str, _reason: String) -> Result<(), String> { + Ok(()) + } + + async fn get_permission_mode(&self) -> Result { + Ok(RemotePermissionMode::Ask) + } + + async fn set_permission_mode( + &self, + mode: RemotePermissionMode, + ) -> Result { + Ok(mode) + } + + fn answer_question( + &self, + _identity: &RemoteUserInputIdentity, + _answers: serde_json::Value, + ) -> Result<(), String> { + self.answer_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +#[tokio::test] +async fn remote_connect_legacy_answer_question_returns_structured_upgrade_required() { + let command = serde_json::from_value::(serde_json::json!({ + "cmd": "answer_question", + "tool_id": "question-1", + "answers": { "0": "yes" } + })) + .expect("legacy payload decodes"); + let host = RecordingInteractionHost::default(); + + let response = handle_remote_interaction_command(&host, &command).await; + + assert_eq!(host.answer_calls.load(Ordering::SeqCst), 0); + assert_eq!( + serde_json::to_value(response).expect("serialize compatibility response"), + serde_json::json!({ + "resp": "error", + "code": "upgrade_required", + "capability": "answer_question_identity_v1", + "message": "This version cannot safely answer the question because it did not send the full registration identity.", + "recovery": "Upgrade the mobile client, reconnect to Desktop, and answer the question again." + }) + ); +} + #[test] fn remote_connect_workspace_response_helpers_own_wire_shape() { let workspace = RemoteWorkspaceFacts { @@ -1683,11 +1769,19 @@ fn remote_connect_workspace_response_helpers_own_wire_shape() { assert_eq!(info_json["assistant_id"], "assistant-1"); assert_eq!(info_json["remote_connection_id"], "ssh-1"); assert_eq!(info_json["remote_ssh_host"], "dev-host"); + assert_eq!( + info_json["capabilities"], + serde_json::json!(["answer_question_identity_v1"]) + ); let empty_json = serde_json::to_value(remote_workspace_info_response(None)).expect("serialize empty info"); assert_eq!(empty_json["resp"], "workspace_info"); assert_eq!(empty_json["has_workspace"], false); + assert_eq!( + empty_json["capabilities"], + serde_json::json!(["answer_question_identity_v1"]) + ); assert!(empty_json.get("workspace_kind").is_none()); let recent_json = serde_json::to_value(remote_recent_workspaces_response(vec![ @@ -1832,6 +1926,10 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { assert_eq!(initial_json["has_more_sessions"], true); assert_eq!(initial_json["sessions"].as_array().unwrap().len(), 3); assert_eq!(initial_json["authenticated_user_id"], "user-1"); + assert_eq!( + initial_json["capabilities"], + serde_json::json!(["answer_question_identity_v1"]) + ); assert_eq!( remote_session_created_response("session-new"), @@ -1928,6 +2026,7 @@ fn remote_connect_message_dtos_keep_current_wire_shape() { start_ms: Some(42), input_preview: Some("{\"cmd\":\"git status\"}".to_string()), tool_input: None, + user_input_identity: None, }]), thinking: None, items: Some(vec![ChatMessageItem { @@ -2105,6 +2204,7 @@ fn remote_connect_response_wire_shape_lives_in_owner_contract() { start_ms: Some(42), input_preview: Some("{\"path\":\"README.md\"}".to_string()), tool_input: None, + user_input_identity: None, }], round_index: 2, items: Some(vec![ChatMessageItem { @@ -2338,21 +2438,52 @@ fn remote_model_catalog_version_tracks_provider_catalog_revision() { struct RecordingTrackerHost { subscribed: Mutex>, unsubscribed: Mutex>, - active_turn_id: Mutex>, + active_turn_seed: Mutex>, + tracker: Mutex>>, + resolve_before_seed_returns: bool, } impl RecordingTrackerHost { fn with_active_turn(turn_id: impl Into) -> Self { Self { - active_turn_id: Mutex::new(Some(turn_id.into())), + active_turn_seed: Mutex::new(Some(RemoteActiveTurnSeed { + turn_id: turn_id.into(), + pending_user_inputs: Vec::new(), + })), ..Self::default() } } + + fn with_pending_question() -> Self { + Self { + active_turn_seed: Mutex::new(Some(RemoteActiveTurnSeed { + turn_id: "turn-1".to_string(), + pending_user_inputs: vec![RemotePendingUserInputSeed { + identity: RemoteUserInputIdentity { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + tool_id: "question-1".to_string(), + registration_sequence: 7, + }, + params: serde_json::json!({ "questions": [{ "question": "Continue?" }] }), + }], + })), + ..Self::default() + } + } + + fn with_pending_question_resolved_before_hydrate() -> Self { + Self { + resolve_before_seed_returns: true, + ..Self::with_pending_question() + } + } } impl RemoteSessionTrackerHost for RecordingTrackerHost { - fn subscribe_tracker(&self, session_id: &str, _tracker: Arc) { + fn subscribe_tracker(&self, session_id: &str, tracker: Arc) { self.subscribed.lock().unwrap().push(session_id.to_string()); + *self.tracker.lock().unwrap() = Some(tracker); } fn unsubscribe_tracker(&self, session_id: &str) { @@ -2362,11 +2493,144 @@ impl RemoteSessionTrackerHost for RecordingTrackerHost { .push(session_id.to_string()); } - fn active_turn_id(&self, _session_id: &str) -> Option { - self.active_turn_id.lock().unwrap().clone() + fn active_turn_seed(&self, session_id: &str) -> Option { + assert_eq!( + self.subscribed.lock().unwrap().as_slice(), + &[session_id.to_string()], + "tracker must subscribe before capturing the owner seed" + ); + let seed = self.active_turn_seed.lock().unwrap().clone(); + if self.resolve_before_seed_returns { + let pending = seed + .as_ref() + .and_then(|seed| seed.pending_user_inputs.first()) + .expect("interleaving fixture pending question"); + self.tracker + .lock() + .unwrap() + .as_ref() + .expect("subscribed tracker") + .handle_agentic_event(&AgenticEvent::ToolEvent { + session_id: pending.identity.session_id.clone(), + turn_id: pending.identity.turn_id.clone(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputResolved { + identity: bitfun_events::ToolEventIdentity::direct( + &pending.identity.tool_id, + "AskUserQuestion", + ), + registration_sequence: pending.identity.registration_sequence, + }, + }); + } + seed + } +} + +struct BlockingTrackerHost { + subscribe_entered: Barrier, + release_subscribe: Barrier, + subscriptions: AtomicUsize, + unsubscriptions: AtomicUsize, +} + +struct BlockingUnsubscribeTrackerHost { + unsubscribe_entered: Barrier, + release_unsubscribe: Barrier, + subscriptions: AtomicUsize, + unsubscriptions: AtomicUsize, +} + +impl BlockingUnsubscribeTrackerHost { + fn new() -> Self { + Self { + unsubscribe_entered: Barrier::new(2), + release_unsubscribe: Barrier::new(2), + subscriptions: AtomicUsize::new(0), + unsubscriptions: AtomicUsize::new(0), + } } } +impl RemoteSessionTrackerHost for BlockingUnsubscribeTrackerHost { + fn subscribe_tracker(&self, _session_id: &str, _tracker: Arc) { + self.subscriptions.fetch_add(1, Ordering::SeqCst); + } + + fn unsubscribe_tracker(&self, _session_id: &str) { + self.unsubscriptions.fetch_add(1, Ordering::SeqCst); + self.unsubscribe_entered.wait(); + self.release_unsubscribe.wait(); + } + + fn active_turn_seed(&self, _session_id: &str) -> Option { + None + } +} + +impl BlockingTrackerHost { + fn new() -> Self { + Self { + subscribe_entered: Barrier::new(2), + release_subscribe: Barrier::new(2), + subscriptions: AtomicUsize::new(0), + unsubscriptions: AtomicUsize::new(0), + } + } +} + +impl RemoteSessionTrackerHost for BlockingTrackerHost { + fn subscribe_tracker(&self, _session_id: &str, _tracker: Arc) { + self.subscriptions.fetch_add(1, Ordering::SeqCst); + self.subscribe_entered.wait(); + self.release_subscribe.wait(); + } + + fn unsubscribe_tracker(&self, _session_id: &str) { + self.unsubscriptions.fetch_add(1, Ordering::SeqCst); + } + + fn active_turn_seed(&self, _session_id: &str) -> Option { + None + } +} + +#[test] +fn remote_connect_late_attach_hydrates_owner_pending_question_after_subscription() { + let registry = RemoteSessionTrackerRegistry::new(); + let host = RecordingTrackerHost::with_pending_question(); + + let tracker = registry.ensure_tracker_with_host("session-1", &host); + let active = tracker.snapshot_active_turn().expect("active turn seeded"); + assert_eq!(active.turn_id, "turn-1"); + assert_eq!(active.tools.len(), 1); + assert_eq!(active.tools[0].status, "waiting_for_user"); + assert_eq!( + active.tools[0] + .user_input_identity + .as_ref() + .expect("authoritative question identity") + .registration_sequence, + 7 + ); +} + +#[test] +fn remote_connect_late_attach_does_not_revive_question_resolved_before_hydrate() { + let registry = RemoteSessionTrackerRegistry::new(); + let host = RecordingTrackerHost::with_pending_question_resolved_before_hydrate(); + + let tracker = registry.ensure_tracker_with_host("session-1", &host); + let active = tracker.snapshot_active_turn().expect("active turn seeded"); + assert_eq!(active.turn_id, "turn-1"); + assert!( + active.tools.is_empty(), + "the captured pending snapshot must not revive the resolved registration" + ); +} + #[test] fn remote_connect_tracker_registry_owns_lifecycle_without_core_state() { let registry = RemoteSessionTrackerRegistry::new(); @@ -2399,6 +2663,126 @@ fn remote_connect_tracker_registry_owns_lifecycle_without_core_state() { ); } +#[test] +fn remote_connect_tracker_registry_does_not_publish_before_initialization() { + let registry = Arc::new(RemoteSessionTrackerRegistry::new()); + let host = Arc::new(BlockingTrackerHost::new()); + let first_registry = registry.clone(); + let first_host = host.clone(); + let first = std::thread::spawn(move || { + first_registry.ensure_tracker_with_host("session-1", first_host.as_ref()) + }); + host.subscribe_entered.wait(); + + let second_registry = registry.clone(); + let second_host = host.clone(); + let (second_tx, second_rx) = mpsc::channel(); + let second = std::thread::spawn(move || { + let tracker = second_registry.ensure_tracker_with_host("session-1", second_host.as_ref()); + second_tx.send(tracker).expect("report second tracker"); + }); + assert!( + second_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "a concurrent ensure must wait until subscription and hydration finish" + ); + + host.release_subscribe.wait(); + let first_tracker = first.join().expect("first ensure"); + let second_tracker = second_rx + .recv_timeout(Duration::from_secs(1)) + .expect("second ensure after initialization"); + second.join().expect("second ensure task"); + assert!(Arc::ptr_eq(&first_tracker, &second_tracker)); + assert_eq!(host.subscriptions.load(Ordering::SeqCst), 1); +} + +#[test] +fn remote_connect_tracker_removal_waits_for_initialization_without_ghost_subscription() { + let registry = Arc::new(RemoteSessionTrackerRegistry::new()); + let host = Arc::new(BlockingTrackerHost::new()); + let ensure_registry = registry.clone(); + let ensure_host = host.clone(); + let ensure = std::thread::spawn(move || { + ensure_registry.ensure_tracker_with_host("session-1", ensure_host.as_ref()) + }); + host.subscribe_entered.wait(); + + let remove_registry = registry.clone(); + let remove_host = host.clone(); + let (remove_tx, remove_rx) = mpsc::channel(); + let remove = std::thread::spawn(move || { + let removed = remove_registry.remove_tracker_with_host("session-1", remove_host.as_ref()); + remove_tx.send(removed.is_some()).expect("report removal"); + }); + assert!( + remove_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "removal must not race ahead of subscription initialization" + ); + + host.release_subscribe.wait(); + ensure.join().expect("ensure task"); + assert!(remove_rx + .recv_timeout(Duration::from_secs(1)) + .expect("removal after initialization")); + remove.join().expect("remove task"); + assert!(registry.get_tracker("session-1").is_none()); + assert_eq!(host.subscriptions.load(Ordering::SeqCst), 1); + assert_eq!(host.unsubscriptions.load(Ordering::SeqCst), 1); +} + +#[test] +fn remote_connect_tracker_replacement_waits_for_previous_unsubscribe() { + let registry = Arc::new(RemoteSessionTrackerRegistry::new()); + let host = Arc::new(BlockingUnsubscribeTrackerHost::new()); + let previous = registry.ensure_tracker_with_host("session-1", host.as_ref()); + + let remove_registry = registry.clone(); + let remove_host = host.clone(); + let remove = std::thread::spawn(move || { + remove_registry.remove_tracker_with_host("session-1", remove_host.as_ref()) + }); + host.unsubscribe_entered.wait(); + + let ensure_registry = registry.clone(); + let ensure_host = host.clone(); + let (ensure_tx, ensure_rx) = mpsc::channel(); + let ensure = std::thread::spawn(move || { + let tracker = ensure_registry.ensure_tracker_with_host("session-1", ensure_host.as_ref()); + ensure_tx.send(tracker).expect("report replacement tracker"); + }); + let early_replacement = ensure_rx.recv_timeout(Duration::from_millis(50)); + let replacement_waited = early_replacement.is_err(); + + host.release_unsubscribe.wait(); + let removed = remove + .join() + .expect("remove task") + .expect("previous tracker removed"); + let replacement = match early_replacement { + Ok(tracker) => tracker, + Err(_) => ensure_rx + .recv_timeout(Duration::from_secs(1)) + .expect("replacement after previous unsubscribe"), + }; + ensure.join().expect("replacement ensure task"); + + assert!( + replacement_waited, + "a replacement must not subscribe before the previous generation unsubscribes" + ); + assert!(Arc::ptr_eq(&previous, &removed)); + assert!(!Arc::ptr_eq(&previous, &replacement)); + assert!(Arc::ptr_eq( + registry + .get_tracker("session-1") + .as_ref() + .expect("replacement registry entry"), + &replacement + )); + assert_eq!(host.subscriptions.load(Ordering::SeqCst), 2); + assert_eq!(host.unsubscriptions.load(Ordering::SeqCst), 1); +} + #[test] fn remote_connect_tracker_preserves_streaming_snapshot_contract() { let tracker = RemoteSessionStateTracker::new("session-1".to_string()); @@ -2544,6 +2928,348 @@ async fn remote_connect_tracker_broadcasts_tool_and_turn_events() { } } +#[tokio::test] +async fn remote_connect_tracker_only_authorizes_question_answers_after_ready_event() { + let tracker = RemoteSessionStateTracker::new("session-1".to_string()); + let mut events = tracker.subscribe(); + let tool_identity = bitfun_events::ToolEventIdentity::direct("tool-1", "AskUserQuestion"); + let params = serde_json::json!({ "questions": [{ "question": "Continue?" }] }); + + tracker.handle_agentic_event(&AgenticEvent::DialogTurnStarted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + turn_index: 0, + user_input: "hello".to_string(), + original_user_input: None, + user_message_metadata: None, + }); + tracker.handle_agentic_event(&AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Started { + identity: tool_identity.clone(), + params: params.clone(), + timeout_seconds: None, + }, + }); + + assert!( + tracker.snapshot_active_turn().expect("active turn").tools[0] + .user_input_identity + .is_none() + ); + assert!(matches!( + events.recv().await.expect("started event"), + TrackerEvent::ToolStarted { .. } + )); + + tracker.handle_agentic_event(&AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputRequested { + identity: tool_identity.clone(), + registration_sequence: 7, + params: params.clone(), + }, + }); + + let expected_identity = RemoteUserInputIdentity { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + tool_id: "tool-1".to_string(), + registration_sequence: 7, + }; + match events.recv().await.expect("ready event") { + TrackerEvent::UserInputRequested { + identity, + params: actual, + } => { + assert_eq!(identity, expected_identity); + assert_eq!(actual, params); + } + other => panic!("unexpected event: {other:?}"), + } + let tool = &tracker.snapshot_active_turn().expect("active turn").tools[0]; + assert_eq!(tool.status, "waiting_for_user"); + assert_eq!(tool.user_input_identity.as_ref(), Some(&expected_identity)); + + tracker.handle_agentic_event(&AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputResolved { + identity: tool_identity.clone(), + registration_sequence: 6, + }, + }); + let tool = &tracker.snapshot_active_turn().expect("active turn").tools[0]; + assert_eq!(tool.status, "waiting_for_user"); + assert_eq!(tool.user_input_identity.as_ref(), Some(&expected_identity)); + + tracker.handle_agentic_event(&AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputResolved { + identity: tool_identity.clone(), + registration_sequence: 7, + }, + }); + let tool = &tracker.snapshot_active_turn().expect("active turn").tools[0]; + assert_eq!(tool.status, "completed"); + assert_eq!(tool.user_input_identity.as_ref(), Some(&expected_identity)); + + tracker.handle_agentic_event(&AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Completed { + identity: tool_identity, + result: serde_json::json!({ "status": "answered" }), + result_for_assistant: None, + image_attachments: None, + duration_ms: 1, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + }, + }); + assert_eq!( + tracker.snapshot_active_turn().expect("active turn").tools[0].status, + "completed" + ); +} + +#[test] +fn remote_connect_tracker_hydrates_same_tool_id_registrations_as_distinct_questions() { + let tracker = RemoteSessionStateTracker::new("session-1".to_string()); + tracker.hydrate_active_turn(RemoteActiveTurnSeed { + turn_id: "turn-1".to_string(), + pending_user_inputs: vec![ + RemotePendingUserInputSeed { + identity: RemoteUserInputIdentity { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + tool_id: "question-1".to_string(), + registration_sequence: 7, + }, + params: serde_json::json!({ "questions": [{ "question": "First?" }] }), + }, + RemotePendingUserInputSeed { + identity: RemoteUserInputIdentity { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + tool_id: "question-1".to_string(), + registration_sequence: 8, + }, + params: serde_json::json!({ "questions": [{ "question": "Second?" }] }), + }, + ], + }); + + let snapshot = tracker.snapshot_active_turn().expect("active turn"); + let identities = snapshot + .tools + .iter() + .filter_map(|tool| tool.user_input_identity.as_ref()) + .collect::>(); + assert_eq!(identities.len(), 2); + assert_eq!(identities[0].registration_sequence, 7); + assert_eq!(identities[1].registration_sequence, 8); +} + +#[test] +fn remote_connect_tracker_settles_each_resolved_same_tool_id_registration_exactly() { + let tracker = RemoteSessionStateTracker::new("session-1".to_string()); + let identities = [7, 8].map(|registration_sequence| RemoteUserInputIdentity { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + tool_id: "question-1".to_string(), + registration_sequence, + }); + tracker.hydrate_active_turn(RemoteActiveTurnSeed { + turn_id: "turn-1".to_string(), + pending_user_inputs: identities + .iter() + .cloned() + .map(|identity| RemotePendingUserInputSeed { + identity, + params: serde_json::json!({ "questions": [{ "question": "Continue?" }] }), + }) + .collect(), + }); + + let tool_identity = bitfun_events::ToolEventIdentity::direct("question-1", "AskUserQuestion"); + for registration_sequence in [7, 8] { + tracker.handle_agentic_event(&AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputResolved { + identity: tool_identity.clone(), + registration_sequence, + }, + }); + } + let resolved = tracker.snapshot_active_turn().expect("active turn"); + assert_eq!(resolved.tools.len(), 2); + assert!(resolved + .tools + .iter() + .all(|tool| { tool.status == "completed" && tool.user_input_identity.is_some() })); + assert_eq!( + resolved + .tools + .iter() + .filter_map(|tool| tool.user_input_identity.as_ref()) + .collect::>(), + identities.iter().collect::>() + ); + + tracker.handle_agentic_event(&AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Completed { + identity: tool_identity, + result: serde_json::json!({ "status": "answered" }), + result_for_assistant: None, + image_attachments: None, + duration_ms: 1, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + }, + }); + + let snapshot = tracker.snapshot_active_turn().expect("active turn"); + assert_eq!(snapshot.tools.len(), 2); + assert!(snapshot.tools.iter().all(|tool| tool.status == "completed")); +} + +#[test] +fn remote_connect_tracker_resolves_only_the_exact_same_tool_id_registration() { + let tracker = RemoteSessionStateTracker::new("session-1".to_string()); + tracker.handle_agentic_event(&AgenticEvent::DialogTurnStarted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + turn_index: 0, + user_input: "hello".to_string(), + original_user_input: None, + user_message_metadata: None, + }); + let tool_identity = bitfun_events::ToolEventIdentity::direct("question-1", "AskUserQuestion"); + for registration_sequence in [7, 8] { + let params = serde_json::json!({ + "questions": [{ "question": format!("Question {registration_sequence}?") }] + }); + tracker.handle_agentic_event(&AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Started { + identity: tool_identity.clone(), + params: params.clone(), + timeout_seconds: None, + }, + }); + tracker.handle_agentic_event(&AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputRequested { + identity: tool_identity.clone(), + registration_sequence, + params, + }, + }); + } + + tracker.handle_agentic_event(&AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::UserInputResolved { + identity: tool_identity.clone(), + registration_sequence: 7, + }, + }); + + tracker.handle_agentic_event(&AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Completed { + identity: tool_identity, + result: serde_json::json!({ "status": "answered" }), + result_for_assistant: None, + image_attachments: None, + duration_ms: 1, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + }, + }); + + let snapshot = tracker.snapshot_active_turn().expect("active turn"); + assert_eq!(snapshot.tools.len(), 2); + let first = snapshot + .tools + .iter() + .find(|tool| { + tool.tool_input + .as_ref() + .and_then(|input| input["questions"][0]["question"].as_str()) + == Some("Question 7?") + }) + .expect("first registration remains projected"); + let second = snapshot + .tools + .iter() + .find(|tool| { + tool.user_input_identity + .as_ref() + .is_some_and(|identity| identity.registration_sequence == 8) + }) + .expect("second registration remains addressable"); + assert_eq!(first.status, "completed"); + assert_eq!( + first + .user_input_identity + .as_ref() + .map(|identity| identity.registration_sequence), + Some(7) + ); + assert_eq!(second.status, "waiting_for_user"); +} + #[test] fn remote_connect_tracker_keeps_finished_turn_snapshot_until_persistence_finalizes() { let tracker = RemoteSessionStateTracker::new("session-1".to_string()); diff --git a/src/mobile-web/AGENTS.md b/src/mobile-web/AGENTS.md index 468942be67..77fb8727d0 100644 --- a/src/mobile-web/AGENTS.md +++ b/src/mobile-web/AGENTS.md @@ -28,6 +28,7 @@ Mobile web is the browser-based remote control client for BitFun desktop session Run the focused mobile-web checks after changes: ```bash +pnpm --dir src/mobile-web test pnpm --dir src/mobile-web run type-check pnpm run build:mobile-web ``` diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index b92e4e182e..31426723bd 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "test": "vitest run", "type-check": "tsc --noEmit", "build": "vite build", "preview": "vite preview" @@ -27,6 +28,7 @@ "sass": "^1.93.2", "typescript": "~5.8.3", "vite": "^7.0.4", + "vitest": "^4.1.0", "ws": "^8.19.0" } } diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index e856eaeef9..6d169bb362 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -37,6 +37,7 @@ import { type PollResponse, type ActiveTurnSnapshot, type RemoteToolStatus, + type RemoteUserInputIdentity, type ChatMessage, type ChatMessageItem, type RemoteModelCatalog, @@ -1136,10 +1137,10 @@ const ReadFilesToggle: React.FC<{ tools: RemoteToolStatus[] }> = ({ tools }) => {open && (
- {tools.map(t => { + {tools.map((t, index) => { const preview = t.input_preview || ''; return ( -
+
{t.status === 'completed' ? '✓' : '⋯'} {t.name} {preview}
); @@ -1175,8 +1176,8 @@ const ToolList: React.FC<{ if (tools.length <= TOOL_LIST_COLLAPSE_THRESHOLD) { return (
- {tools.map((tc) => ( - + {tools.map((tc, index) => ( + ))}
); @@ -1199,8 +1200,8 @@ const ToolList: React.FC<{
{expanded && (
- {tools.map((tc) => ( - + {tools.map((tc, index) => ( + ))}
)} @@ -1293,14 +1294,27 @@ const TypewriterText: React.FC<{ interface AskQuestionCardProps { tool: RemoteToolStatus; - onAnswer: (toolId: string, answers: any) => Promise; + onAnswer: (identity: RemoteUserInputIdentity, answers: any) => Promise; } const isPendingAskUserQuestion = (tool?: RemoteToolStatus | null) => { - if (!tool || tool.name !== 'AskUserQuestion' || !tool.tool_input) return false; + if (!tool || tool.name !== 'AskUserQuestion' || !tool.tool_input || !tool.user_input_identity) return false; return !['completed', 'failed', 'cancelled', 'rejected'].includes(tool.status); }; +const remoteToolRenderKey = (tool: RemoteToolStatus, index: number) => { + const identity = tool.user_input_identity; + if (identity) { + return [ + identity.session_id, + identity.turn_id, + identity.tool_id, + identity.registration_sequence, + ].join(':'); + } + return `${tool.id || tool.name || 'tool'}:${index}`; +}; + function getMessageByPath(source: unknown, path: string): string | null { const segments = path.split('.'); let current: unknown = source; @@ -1335,6 +1349,7 @@ const AskQuestionCard: React.FC = ({ tool, onAnswer }) => const [customTexts, setCustomTexts] = useState>({}); const [submitting, setSubmitting] = useState(false); const [submitted, setSubmitted] = useState(false); + const identity = tool.user_input_identity; const normalizedQuestions = useMemo(() => { return questions.map((q) => { @@ -1357,7 +1372,7 @@ const AskQuestionCard: React.FC = ({ tool, onAnswer }) => }; const handleSubmit = async () => { - if (!allAnswered || submitting || submitted) return; + if (!identity || !allAnswered || submitting || submitted) return; const answers: Record = {}; normalizedQuestions.forEach((q, idx) => { @@ -1374,7 +1389,7 @@ const AskQuestionCard: React.FC = ({ tool, onAnswer }) => setSubmitting(true); try { - await onAnswer(tool.id, answers); + await onAnswer(identity, answers); setSubmitted(true); } finally { setSubmitting(false); @@ -1420,7 +1435,7 @@ const AskQuestionCard: React.FC = ({ tool, onAnswer }) => key={oIdx} className={`chat-ask-card__option ${isSelected ? 'is-selected' : ''}`} onClick={() => handleSelect(qIdx, opt.label, q.multiSelect)} - disabled={submitted || submitting} + disabled={!identity || submitted || submitting} > {isSelected && ( @@ -1440,7 +1455,7 @@ const AskQuestionCard: React.FC = ({ tool, onAnswer }) =>
@@ -1468,7 +1483,7 @@ const AskQuestionCard: React.FC = ({ tool, onAnswer }) => })}
@@ -457,7 +465,7 @@ export const AskUserQuestionCard: React.FC = ({ size="small" className="submit-button" onClick={handleSubmit} - disabled={!isAllAnswered() || isSubmitting || Boolean(isParamsStreaming)} + disabled={!userInputReady || !isAllAnswered() || isSubmitting || Boolean(isParamsStreaming)} isLoading={isSubmitting} title={!isAllAnswered() ? t('toolCards.askUser.answerAllBeforeSubmit') : ""} > diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 33613a5c4d..93715e3d2c 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -69,6 +69,15 @@ export interface FlowToolItem extends FlowItem { }; requiresConfirmation?: boolean; userConfirmed?: boolean; + /** Runtime owner has registered the pending AskUserQuestion response channel. */ + userInputReady?: boolean; + /** Exact Runtime-owned identity required to answer this question. */ + userInputIdentity?: { + sessionId: string; + turnId: string; + toolId: string; + registrationSequence: number; + }; acpPermission?: { permissionId: string; sessionId?: string; diff --git a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts index accc2d3e7b..7d71ad61ae 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts @@ -5,6 +5,7 @@ import { decodeResponseBody, encodeRequestBody, resolveWsMethod, + SESSION_OWNER_REFRESH_EVENT, WebSocketTransportAdapter, } from './websocket-adapter'; import type { @@ -12,6 +13,101 @@ import type { SubmitDialogTurnResponse, } from '@/generated/api'; +class ProtocolMockWebSocket { + static readonly OPEN = 1; + + readyState = 0; + sent: Array> = []; + onopen: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + + constructor(readonly url: string) {} + + open(): void { + this.readyState = ProtocolMockWebSocket.OPEN; + this.onopen?.({} as Event); + } + + receive(message: Record): void { + this.onmessage?.({ data: JSON.stringify(message) } as MessageEvent); + } + + send(payload: string): void { + this.sent.push(JSON.parse(payload) as Record); + } + + close(): void { + this.readyState = 3; + this.onclose?.({} as CloseEvent); + } +} + +function installProtocolWebSocket(): ProtocolMockWebSocket[] { + const sockets: ProtocolMockWebSocket[] = []; + class TestWebSocket extends ProtocolMockWebSocket { + constructor(url: string) { + super(url); + sockets.push(this); + } + } + vi.stubGlobal('WebSocket', TestWebSocket); + return sockets; +} + +function validInitializeResult(maxFrameBytes = 256 * 1024): Record { + return { + protocolVersion: 4, + minimumProtocolVersion: 4, + server: { name: 'bitfun-app-server', version: '0.2.18' }, + capabilities: [ + { + id: 'agent', + availability: { availability: 'available' }, + methods: ['agent/listSessions'], + }, + { + id: 'eventSync', + availability: { availability: 'available' }, + methods: ['app/syncEvents', 'app/eventStreamState'], + }, + ], + limits: { maxFrameBytes, eventBufferCapacity: 1024 }, + }; +} + +function validSyncEventsResult( + overrides: Record = {}, +): Record { + return { + cursors: [ + { connectionId: 'app-server-1', stream: 'agent', sequence: 0 }, + { connectionId: 'app-server-1', stream: 'permission', sequence: 0 }, + { connectionId: 'app-server-1', stream: 'config', sequence: 0 }, + ], + pendingPermissions: [], + agentSnapshotAvailable: false, + configSnapshotAvailable: false, + externalSourceSnapshotAvailable: false, + ...overrides, + }; +} + +function permissionRequest(requestId: string): Record { + return { + requestId, + roundId: 'round-1', + order: 0, + projectId: 'project-1', + sessionId: 'session-1', + agentId: 'agentic', + action: 'filesystem.read', + resources: [`/workspace/${requestId}.txt`], + source: { kind: 'tool_call', identity: `tool-${requestId}` }, + }; +} + describe('resolveWsMethod', () => { it('maps every app-server agent command to its agent/* surface method', () => { // The snake_case Tauri command names the service-API call sites use are @@ -32,9 +128,9 @@ describe('resolveWsMethod', () => { expect(resolveWsMethod('start_dialog_turn')).toBe('agent/submitDialogTurn'); expect(resolveWsMethod('cancel_dialog_turn')).toBe('agent/cancelTurn'); // Permission surface: reply/list/grants map to the app-server permission - // methods. `subscribe_permission_requests` is satisfied by the - // `permission://event` push in web mode; mapping it to list fetches the - // current pending set once. + // methods. `subscribe_permission_requests` is satisfied by the adapter's + // typed `agent/permissionEvent` + `app/syncEvents` projection; this command + // mapping retains the current pending-list compatibility call. expect(resolveWsMethod('respond_permission')).toBe('agent/respondPermission'); expect(resolveWsMethod('respond_permission_batch')).toBe( 'agent/respondPermissionBatch' @@ -279,24 +375,156 @@ describe('encodeRequestBody', () => { }); describe('decodeWsNotification', () => { + it('projects a real v4 agent/event envelope to the existing agentic event bus', () => { + expect(decodeWsNotification({ + jsonrpc: '2.0', + method: 'agent/event', + params: { + cursor: { connectionId: 'app-server-1', stream: 'agent', sequence: 1 }, + event: { + id: 'event-1', + event: { + type: 'TextChunk', + session_id: 'session-1', + turn_id: 'turn-1', + round_id: 'round-1', + text: 'hello', + }, + priority: 'Normal', + timestamp: { secs_since_epoch: 1, nanos_since_epoch: 0 }, + }, + }, + })).toEqual({ + event: 'agentic://text-chunk', + payload: { + sessionId: 'session-1', + turnId: 'turn-1', + roundId: 'round-1', + text: 'hello', + }, + }); + }); + + it('projects the canonical SessionHistoryChanged transcript invalidation', () => { + expect(decodeWsNotification({ + jsonrpc: '2.0', + method: 'agent/event', + params: { + cursor: { connectionId: 'app-server-1', stream: 'agent', sequence: 2 }, + event: { + id: 'event-history-1', + event: { + type: 'SessionHistoryChanged', + session_id: 'session-1', + }, + priority: 'Normal', + timestamp: { secs_since_epoch: 1, nanos_since_epoch: 0 }, + }, + }, + })).toEqual({ + event: 'agentic://session-history-changed', + payload: { sessionId: 'session-1' }, + }); + }); + + it('keeps the canonical ToolEvent payload opaque while projecting envelope fields', () => { + const toolEvent = { + event_type: 'UserInputRequested', + tool_id: 'question-1', + tool_name: 'AskUserQuestion', + registration_sequence: 7, + params: { + questions: [{ question: 'Continue?', header_text: 'Confirm' }], + }, + }; + expect(decodeWsNotification({ + jsonrpc: '2.0', + method: 'agent/event', + params: { + cursor: { connectionId: 'app-server-1', stream: 'agent', sequence: 3 }, + event: { + id: 'event-tool-1', + event: { + type: 'ToolEvent', + session_id: 'session-1', + turn_id: 'turn-1', + round_id: 'round-1', + tool_event: toolEvent, + }, + priority: 'Normal', + timestamp: { secs_since_epoch: 1, nanos_since_epoch: 0 }, + }, + }, + })).toEqual({ + event: 'agentic://tool-event', + payload: { + sessionId: 'session-1', + turnId: 'turn-1', + roundId: 'round-1', + toolEvent, + }, + }); + }); + + it('projects a real v4 agent/permissionEvent envelope to permission listeners', () => { + const request = { + requestId: 'permission-1', + roundId: 'round-1', + order: 0, + projectId: 'project-1', + sessionId: 'session-1', + agentId: 'agentic', + action: 'filesystem.read', + resources: ['/workspace/file.txt'], + source: { kind: 'tool_call', identity: 'tool-1' }, + }; + + expect(decodeWsNotification({ + jsonrpc: '2.0', + method: 'agent/permissionEvent', + params: { + cursor: { connectionId: 'app-server-1', stream: 'permission', sequence: 2 }, + event: { event: 'asked', request }, + }, + })).toEqual({ + event: 'permission://event', + payload: { event: 'asked', request }, + }); + }); + it('projects typed config notifications to a stable frontend event', () => { expect(decodeWsNotification({ jsonrpc: '2.0', method: 'config/event', - params: { kind: 'modelConfigurationUpdated' }, + params: { + cursor: { connectionId: 'app-server-1', stream: 'config', sequence: 3 }, + event: { kind: 'modelConfigurationUpdated' }, + }, })).toEqual({ event: 'config://updated', payload: { kind: 'modelConfigurationUpdated' }, }); }); - it('keeps projected agent notifications unchanged', () => { + it('projects v4 stream invalidation to an explicit transport event', () => { + const params = { + cursor: { connectionId: 'app-server-1', stream: 'agent', sequence: 4 }, + stream: 'agent', + state: 'invalidated', + resync: { + method: 'session/sync', + snapshotAvailable: false, + reason: 'event buffer lagged', + }, + }; + expect(decodeWsNotification({ - method: 'agent/frontendEvent', - params: { event: 'agentic://session-created', payload: { sessionId: 's1' } }, + jsonrpc: '2.0', + method: 'app/eventStreamState', + params, })).toEqual({ - event: 'agentic://session-created', - payload: { sessionId: 's1' }, + event: 'app-server://event-stream-state', + payload: params, }); }); }); @@ -342,6 +570,855 @@ describe('decodeResponseBody', () => { }); }); +describe('WebSocketTransportAdapter protocol negotiation', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('waits for a valid App Server v4 initialize response before sending business requests', async () => { + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const connection = adapter.connect(); + + sockets[0].open(); + await Promise.resolve(); + + expect(sockets[0].sent).toHaveLength(1); + const initialize = sockets[0].sent[0]; + expect(initialize).toMatchObject({ + jsonrpc: '2.0', + method: 'app/initialize', + params: { + protocolVersion: 4, + client: { + name: 'bitfun-web-ui', + }, + }, + }); + + let connected = false; + void connection.then(() => { + connected = true; + }); + const businessRequest = adapter.request('list_sessions', {}); + await Promise.resolve(); + expect(connected).toBe(false); + expect(adapter.isConnected()).toBe(false); + expect(sockets[0].sent).toHaveLength(1); + + sockets[0].receive({ + jsonrpc: '2.0', + id: initialize.id, + result: validInitializeResult(), + }); + await Promise.resolve(); + + expect(connected).toBe(false); + expect(adapter.isConnected()).toBe(false); + expect(sockets[0].sent).toHaveLength(2); + expect(sockets[0].sent[1]).toMatchObject({ + jsonrpc: '2.0', + method: 'app/syncEvents', + params: { streams: ['agent', 'permission', 'config'] }, + }); + + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[1].id, + result: validSyncEventsResult(), + }); + await connection; + + expect(connected).toBe(true); + expect(adapter.isConnected()).toBe(true); + await vi.waitFor(() => expect(sockets[0].sent).toHaveLength(3)); + expect(sockets[0].sent[2]).toMatchObject({ + jsonrpc: '2.0', + method: 'agent/listSessions', + params: {}, + }); + + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[2].id, + result: { sessions: [] }, + }); + await expect(businessRequest).resolves.toEqual([]); + }); + + it.each([ + { + name: 'server maximum below client v4', + result: { ...validInitializeResult(), protocolVersion: 3, minimumProtocolVersion: 3 }, + message: 'protocol version', + }, + { + name: 'server minimum above client v4', + result: { ...validInitializeResult(), protocolVersion: 5, minimumProtocolVersion: 5 }, + message: 'protocol version', + }, + { + name: 'malformed capability descriptors', + result: { ...validInitializeResult(), capabilities: null }, + message: 'capabilities', + }, + { + name: 'non-positive transport limit', + result: { + ...validInitializeResult(), + limits: { maxFrameBytes: 0, eventBufferCapacity: 1024 }, + }, + message: 'transport limits', + }, + ])('rejects $name before the connection becomes ready', async ({ result, message }) => { + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const connection = adapter.connect(); + + sockets[0].open(); + const initialize = sockets[0].sent[0]; + sockets[0].receive({ + jsonrpc: '2.0', + id: initialize.id, + result, + }); + + await expect(connection).rejects.toThrow(message); + expect(adapter.isConnected()).toBe(false); + expect(sockets[0].sent).toHaveLength(1); + await adapter.disconnect(); + }); + + it('rejects an oversized UTF-8 business frame before the WebSocket is disrupted', async () => { + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const connection = adapter.connect(); + sockets[0].open(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[0].id, + result: validInitializeResult(512), + }); + await Promise.resolve(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[1].id, + result: validSyncEventsResult(), + }); + await connection; + + await expect(adapter.request('list_sessions', { + padding: '界'.repeat(512), + })).rejects.toThrow('exceeds negotiated App Server frame limit'); + expect(sockets[0].sent).toHaveLength(2); + expect(sockets[0].readyState).toBe(ProtocolMockWebSocket.OPEN); + expect(adapter.isConnected()).toBe(true); + await adapter.disconnect(); + }); + + it('fails closed without reconnecting after an incompatible initialize result', async () => { + vi.useFakeTimers(); + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const connection = adapter.connect(); + + sockets[0].open(); + const initialize = sockets[0].sent[0]; + sockets[0].receive({ + jsonrpc: '2.0', + id: initialize.id, + result: { ...validInitializeResult(), protocolVersion: 3, minimumProtocolVersion: 3 }, + }); + + await expect(connection).rejects.toThrow('protocol version'); + expect(sockets[0].readyState).toBe(3); + expect(vi.getTimerCount()).toBe(0); + + await vi.advanceTimersByTimeAsync(30_000); + expect(sockets).toHaveLength(1); + }); + + it('fails closed when the server rejects initialize as non-retryable', async () => { + vi.useFakeTimers(); + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const connection = adapter.connect(); + + sockets[0].open(); + const initialize = sockets[0].sent[0]; + sockets[0].receive({ + jsonrpc: '2.0', + id: initialize.id, + error: { + code: -32602, + message: 'Unsupported protocol version', + data: { capability: 'app.initialize', retryable: false }, + }, + }); + + await expect(connection).rejects.toThrow('Unsupported protocol version'); + expect(sockets[0].readyState).toBe(3); + expect(vi.getTimerCount()).toBe(0); + + await vi.advanceTimersByTimeAsync(30_000); + expect(sockets).toHaveLength(1); + }); + + it('renegotiates after reconnect and holds business requests until initialize succeeds', async () => { + vi.useFakeTimers(); + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const firstConnection = adapter.connect(); + + sockets[0].open(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[0].id, + result: validInitializeResult(), + }); + await Promise.resolve(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[1].id, + result: validSyncEventsResult(), + }); + await firstConnection; + + sockets[0].close(); + await vi.advanceTimersByTimeAsync(1_000); + expect(sockets).toHaveLength(2); + + sockets[1].open(); + const initialize = sockets[1].sent[0]; + expect(initialize).toMatchObject({ + method: 'app/initialize', + params: { protocolVersion: 4 }, + }); + + const businessRequest = adapter.request('list_sessions', {}); + await Promise.resolve(); + expect(adapter.isConnected()).toBe(false); + expect(sockets[1].sent).toHaveLength(1); + + sockets[1].receive({ + jsonrpc: '2.0', + id: initialize.id, + result: validInitializeResult(), + }); + await vi.advanceTimersByTimeAsync(0); + expect(adapter.isConnected()).toBe(false); + expect(sockets[1].sent).toHaveLength(2); + expect(sockets[1].sent[1]).toMatchObject({ method: 'app/syncEvents' }); + + sockets[1].receive({ + jsonrpc: '2.0', + id: sockets[1].sent[1].id, + result: validSyncEventsResult({ agentSnapshotAvailable: true }), + }); + await vi.advanceTimersByTimeAsync(0); + expect(adapter.isConnected()).toBe(true); + expect(sockets[1].sent).toHaveLength(3); + expect(sockets[1].sent[2]).toMatchObject({ method: 'agent/listSessions' }); + + sockets[1].receive({ + jsonrpc: '2.0', + id: sockets[1].sent[2].id, + result: { sessions: [] }, + }); + await expect(businessRequest).resolves.toEqual([]); + await adapter.disconnect(); + }); + + it('reconciles permissions by requestId after reconnect and requires a Session owner refresh', async () => { + vi.useFakeTimers(); + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const permissionEvents: Array> = []; + const refreshEvents: Array> = []; + adapter.listen>('permission://event', event => { + permissionEvents.push(event); + }); + adapter.listen>( + 'app-server://session-owner-refresh-required', + event => { + refreshEvents.push(event); + }, + ); + + const firstConnection = adapter.connect(); + sockets[0].open(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[0].id, + result: validInitializeResult(), + }); + await vi.advanceTimersByTimeAsync(0); + const firstPermission = permissionRequest('permission-1'); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[1].id, + result: validSyncEventsResult({ pendingPermissions: [firstPermission] }), + }); + await firstConnection; + expect(permissionEvents).toEqual([ + { event: 'asked', request: firstPermission }, + ]); + + sockets[0].receive({ + jsonrpc: '2.0', + method: 'agent/permissionEvent', + params: { + cursor: { connectionId: 'app-server-1', stream: 'permission', sequence: 1 }, + event: { event: 'asked', request: firstPermission }, + }, + }); + expect(permissionEvents).toHaveLength(1); + + sockets[0].close(); + await vi.advanceTimersByTimeAsync(1_000); + sockets[1].open(); + sockets[1].receive({ + jsonrpc: '2.0', + id: sockets[1].sent[0].id, + result: validInitializeResult(), + }); + await vi.advanceTimersByTimeAsync(0); + + const waitingBusinessRequest = adapter.request('list_sessions', {}); + await vi.advanceTimersByTimeAsync(0); + expect(sockets[1].sent).toHaveLength(2); + + const secondPermission = permissionRequest('permission-2'); + sockets[1].receive({ + jsonrpc: '2.0', + id: sockets[1].sent[1].id, + result: validSyncEventsResult({ pendingPermissions: [secondPermission] }), + }); + + await expect(waitingBusinessRequest).rejects.toThrow( + 'Reload the Web page', + ); + expect(sockets[1].sent).toHaveLength(2); + expect(permissionEvents).toEqual([ + { event: 'asked', request: firstPermission }, + { + event: 'cancelled', + requestId: 'permission-1', + reason: 'Permission request is absent from the authoritative reconnect snapshot', + }, + { event: 'asked', request: secondPermission }, + ]); + expect(refreshEvents).toEqual([{ + generation: 2, + stream: 'agent', + reason: 'agent_snapshot_unavailable', + resyncMethod: 'session/sync', + }]); + expect(sockets[1].readyState).toBe(3); + expect(adapter.isConnected()).toBe(false); + await vi.advanceTimersByTimeAsync(5_000); + expect(sockets).toHaveLength(2); + await adapter.disconnect(); + }); + + it('fails closed on an agent stream invalidation without inventing a Session id', async () => { + vi.useFakeTimers(); + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const streamEvents: Array> = []; + const refreshEvents: Array> = []; + adapter.listen>('app-server://event-stream-state', event => { + streamEvents.push(event); + }); + adapter.listen>( + 'app-server://session-owner-refresh-required', + event => { + refreshEvents.push(event); + }, + ); + + const connection = adapter.connect(); + sockets[0].open(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[0].id, + result: validInitializeResult(), + }); + await Promise.resolve(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[1].id, + result: validSyncEventsResult(), + }); + await connection; + + const invalidation = { + cursor: { connectionId: 'app-server-1', stream: 'agent', sequence: 9 }, + stream: 'agent', + state: 'invalidated', + resync: { + method: 'session/sync', + snapshotAvailable: false, + reason: 'event buffer lagged', + }, + }; + sockets[0].receive({ + jsonrpc: '2.0', + method: 'app/eventStreamState', + params: invalidation, + }); + + expect(streamEvents).toEqual([invalidation]); + expect(refreshEvents).toEqual([{ + generation: 1, + stream: 'agent', + reason: 'event_stream_invalidated', + resyncMethod: 'session/sync', + }]); + expect(refreshEvents[0]).not.toHaveProperty('sessionId'); + await expect(adapter.request('list_sessions', {})).rejects.toThrow( + 'Reload the Web page', + ); + expect(sockets[0].readyState).toBe(3); + expect(adapter.isConnected()).toBe(false); + await vi.advanceTimersByTimeAsync(5_000); + expect(sockets).toHaveLength(1); + await adapter.disconnect(); + }); + + it('replays an existing reload-required state to a late App shell listener', async () => { + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const connection = adapter.connect(); + sockets[0].open(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[0].id, + result: validInitializeResult(), + }); + await Promise.resolve(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[1].id, + result: validSyncEventsResult(), + }); + await connection; + + sockets[0].receive({ + jsonrpc: '2.0', + method: 'app/eventStreamState', + params: { + cursor: { connectionId: 'app-server-1', stream: 'agent', sequence: 4 }, + stream: 'agent', + state: 'invalidated', + resync: { + method: 'session/sync', + snapshotAvailable: false, + reason: 'event buffer lagged', + }, + }, + }); + + const refreshEvents: Array> = []; + adapter.listen>(SESSION_OWNER_REFRESH_EVENT, event => { + refreshEvents.push(event); + }); + expect(refreshEvents).toEqual([{ + generation: 1, + stream: 'agent', + reason: 'event_stream_invalidated', + resyncMethod: 'session/sync', + }]); + await adapter.disconnect(); + }); + + it('reconciles the permission owner after a recoverable stream invalidation', async () => { + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const permissionEvents: Array> = []; + adapter.listen>('permission://event', event => { + permissionEvents.push(event); + }); + + const firstPermission = permissionRequest('permission-1'); + const connection = adapter.connect(); + sockets[0].open(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[0].id, + result: validInitializeResult(), + }); + await Promise.resolve(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[1].id, + result: validSyncEventsResult({ pendingPermissions: [firstPermission] }), + }); + await connection; + + sockets[0].receive({ + jsonrpc: '2.0', + method: 'app/eventStreamState', + params: { + cursor: { connectionId: 'app-server-1', stream: 'permission', sequence: 4 }, + stream: 'permission', + state: 'lagged', + resync: { + method: 'app/syncEvents', + snapshotAvailable: true, + reason: 'event buffer lagged', + }, + }, + }); + + expect(sockets[0].sent).toHaveLength(3); + expect(sockets[0].sent[2]).toMatchObject({ + method: 'app/syncEvents', + params: { streams: ['permission'] }, + }); + const blockedBusinessRequest = adapter.request('list_sessions', {}); + await Promise.resolve(); + expect(sockets[0].sent).toHaveLength(3); + const secondPermission = permissionRequest('permission-2'); + sockets[0].receive({ + jsonrpc: '2.0', + method: 'app/eventStreamState', + params: { + cursor: { connectionId: 'app-server-1', stream: 'permission', sequence: 5 }, + stream: 'permission', + state: 'lagged', + resync: { + method: 'app/syncEvents', + snapshotAvailable: true, + reason: 'a second event buffer lag', + }, + }, + }); + expect(sockets[0].sent).toHaveLength(3); + const thirdPermission = permissionRequest('permission-3'); + sockets[0].receive({ + jsonrpc: '2.0', + method: 'agent/permissionEvent', + params: { + cursor: { connectionId: 'app-server-1', stream: 'permission', sequence: 6 }, + event: { event: 'asked', request: thirdPermission }, + }, + }); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[2].id, + result: validSyncEventsResult({ + cursors: [ + { connectionId: 'app-server-1', stream: 'agent', sequence: 0 }, + { connectionId: 'app-server-1', stream: 'permission', sequence: 5 }, + { connectionId: 'app-server-1', stream: 'config', sequence: 0 }, + ], + pendingPermissions: [secondPermission], + }), + }); + await Promise.resolve(); + await Promise.resolve(); + expect(sockets[0].sent).toHaveLength(4); + expect(sockets[0].sent[3]).toMatchObject({ + method: 'app/syncEvents', + params: { streams: ['permission'] }, + }); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[3].id, + result: validSyncEventsResult({ + cursors: [ + { connectionId: 'app-server-1', stream: 'agent', sequence: 0 }, + { connectionId: 'app-server-1', stream: 'permission', sequence: 6 }, + { connectionId: 'app-server-1', stream: 'config', sequence: 0 }, + ], + pendingPermissions: [thirdPermission], + }), + }); + await vi.waitFor(() => expect(sockets[0].sent).toHaveLength(5)); + expect(sockets[0].sent[4]).toMatchObject({ method: 'agent/listSessions' }); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[4].id, + result: { sessions: [] }, + }); + await expect(blockedBusinessRequest).resolves.toEqual([]); + + expect(permissionEvents).toEqual([ + { event: 'asked', request: firstPermission }, + { + event: 'cancelled', + requestId: 'permission-1', + reason: 'Permission request is absent from the authoritative reconnect snapshot', + }, + { event: 'asked', request: secondPermission }, + { event: 'asked', request: thirdPermission }, + { + event: 'cancelled', + requestId: 'permission-2', + reason: 'Permission request is absent from the authoritative reconnect snapshot', + }, + ]); + expect(sockets[0].readyState).toBe(ProtocolMockWebSocket.OPEN); + await adapter.disconnect(); + }); + + it('reconnects instead of serving from a closed permission event source', async () => { + vi.useFakeTimers(); + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const connection = adapter.connect(); + sockets[0].open(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[0].id, + result: validInitializeResult(), + }); + await Promise.resolve(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[1].id, + result: validSyncEventsResult(), + }); + await connection; + + sockets[0].receive({ + jsonrpc: '2.0', + method: 'app/eventStreamState', + params: { + cursor: { connectionId: 'app-server-1', stream: 'permission', sequence: 4 }, + stream: 'permission', + state: 'closed', + resync: { + method: 'app/syncEvents', + snapshotAvailable: true, + reason: 'permission owner closed', + }, + }, + }); + + expect(sockets[0].readyState).toBe(3); + expect(adapter.isConnected()).toBe(false); + await vi.advanceTimersByTimeAsync(1_000); + expect(sockets).toHaveLength(2); + await adapter.disconnect(); + }); + + it('starts a fresh permission snapshot when the resync socket closes', async () => { + vi.useFakeTimers(); + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const connection = adapter.connect(); + sockets[0].open(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[0].id, + result: validInitializeResult(), + }); + await Promise.resolve(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[1].id, + result: validSyncEventsResult(), + }); + await connection; + + sockets[0].receive({ + jsonrpc: '2.0', + method: 'app/eventStreamState', + params: { + cursor: { connectionId: 'app-server-1', stream: 'permission', sequence: 4 }, + stream: 'permission', + state: 'lagged', + resync: { + method: 'app/syncEvents', + snapshotAvailable: true, + reason: 'event buffer lagged', + }, + }, + }); + expect(sockets[0].sent).toHaveLength(3); + sockets[0].receive({ + jsonrpc: '2.0', + method: 'agent/permissionEvent', + params: { + cursor: { connectionId: 'app-server-1', stream: 'permission', sequence: 5 }, + event: { event: 'asked', request: permissionRequest('stale-permission') }, + }, + }); + + sockets[0].close(); + await vi.advanceTimersByTimeAsync(1_000); + expect(sockets).toHaveLength(2); + sockets[1].open(); + sockets[1].receive({ + jsonrpc: '2.0', + id: sockets[1].sent[0].id, + result: validInitializeResult(), + }); + await vi.advanceTimersByTimeAsync(0); + expect(sockets[1].sent).toHaveLength(2); + expect(sockets[1].sent[1]).toMatchObject({ method: 'app/syncEvents' }); + sockets[1].receive({ + jsonrpc: '2.0', + id: sockets[1].sent[1].id, + result: validSyncEventsResult({ + agentSnapshotAvailable: true, + cursors: [ + { connectionId: 'app-server-2', stream: 'agent', sequence: 0 }, + { connectionId: 'app-server-2', stream: 'permission', sequence: 0 }, + { connectionId: 'app-server-2', stream: 'config', sequence: 0 }, + ], + }), + }); + await vi.advanceTimersByTimeAsync(0); + expect(adapter.isConnected()).toBe(true); + expect(vi.getTimerCount()).toBe(0); + await adapter.disconnect(); + }); + + it('classifies a sent CreateSession with a lost response as outcome unknown', async () => { + vi.useFakeTimers(); + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const connection = adapter.connect(); + sockets[0].open(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[0].id, + result: validInitializeResult(), + }); + await Promise.resolve(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[1].id, + result: validSyncEventsResult(), + }); + await connection; + + let rejection: unknown; + void adapter.request('create_session', { + workspacePath: '/workspace', + agentType: 'agentic', + }).catch(error => { + rejection = error; + }); + await vi.advanceTimersByTimeAsync(0); + expect(sockets[0].sent[2]).toMatchObject({ method: 'agent/createSession' }); + sockets[0].close(); + await vi.advanceTimersByTimeAsync(0); + + try { + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toContain('outcome_unknown:'); + } finally { + await adapter.disconnect(); + } + }); + + it('gates subsequent business after a sent side effect times out', async () => { + vi.useFakeTimers(); + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const refreshEvents: Array> = []; + adapter.listen>(SESSION_OWNER_REFRESH_EVENT, event => { + refreshEvents.push(event); + }); + const connection = adapter.connect(); + sockets[0].open(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[0].id, + result: validInitializeResult(), + }); + await Promise.resolve(); + sockets[0].receive({ + jsonrpc: '2.0', + id: sockets[0].sent[1].id, + result: validSyncEventsResult(), + }); + await connection; + + const create = adapter.request('create_session', { + workspacePath: '/workspace', + agentType: 'agentic', + }); + const createFailure = expect(create).rejects.toThrow('outcome_unknown:'); + await vi.advanceTimersByTimeAsync(30_000); + await createFailure; + + expect(refreshEvents).toEqual([{ + generation: 1, + stream: 'app', + reason: 'side_effect_outcome_unknown', + resyncMethod: 'reload', + }]); + await expect(adapter.request('list_sessions', {})).rejects.toThrow( + 'Reload the Web page', + ); + expect(sockets[0].sent).toHaveLength(3); + await adapter.disconnect(); + }); + + it('cleans up a timed-out initialize before retrying on a fresh connection', async () => { + vi.useFakeTimers(); + const sockets = installProtocolWebSocket(); + const adapter = new WebSocketTransportAdapter('ws://example.test/ws'); + const connection = adapter.connect(); + + sockets[0].open(); + const initialize = sockets[0].sent[0]; + const businessRequest = adapter.request('list_sessions', {}); + const connectionFailure = expect(connection).rejects.toThrow( + 'Request timeout: app/initialize' + ); + const businessFailure = expect(businessRequest).rejects.toThrow( + 'Request timeout: app/initialize' + ); + + await vi.advanceTimersByTimeAsync(30_000); + await connectionFailure; + await businessFailure; + expect(sockets[0].readyState).toBe(3); + expect(vi.getTimerCount()).toBe(1); + + // A late response from the failed connection must not revive it or replay + // the business request that was waiting on that connection. + sockets[0].receive({ + jsonrpc: '2.0', + id: initialize.id, + result: validInitializeResult(), + }); + expect(adapter.isConnected()).toBe(false); + + await vi.advanceTimersByTimeAsync(1_000); + expect(sockets).toHaveLength(2); + sockets[1].open(); + expect(sockets[1].sent).toHaveLength(1); + expect(sockets[1].sent[0]).toMatchObject({ method: 'app/initialize' }); + + sockets[1].receive({ + jsonrpc: '2.0', + id: sockets[1].sent[0].id, + result: validInitializeResult(), + }); + await vi.advanceTimersByTimeAsync(0); + expect(adapter.isConnected()).toBe(false); + expect(sockets[1].sent).toHaveLength(2); + expect(sockets[1].sent[1]).toMatchObject({ method: 'app/syncEvents' }); + sockets[1].receive({ + jsonrpc: '2.0', + id: sockets[1].sent[1].id, + result: validSyncEventsResult(), + }); + await vi.advanceTimersByTimeAsync(0); + expect(adapter.isConnected()).toBe(true); + expect(sockets[1].sent).toHaveLength(2); + await adapter.disconnect(); + }); +}); + describe('WebSocketTransportAdapter reconnect lifecycle', () => { afterEach(() => { vi.useRealTimers(); @@ -356,6 +1433,7 @@ describe('WebSocketTransportAdapter reconnect lifecycle', () => { static readonly OPEN = 1; readyState = 0; + sent: Array> = []; onopen: ((event: Event) => void) | null = null; onerror: ((event: Event) => void) | null = null; onclose: ((event: CloseEvent) => void) | null = null; @@ -370,6 +1448,32 @@ describe('WebSocketTransportAdapter reconnect lifecycle', () => { this.onopen?.({} as Event); } + async completeInitialize(): Promise { + const initialize = this.sent[0]; + this.onmessage?.({ + data: JSON.stringify({ + jsonrpc: '2.0', + id: initialize.id, + result: { + protocolVersion: 4, + minimumProtocolVersion: 4, + server: { name: 'bitfun-app-server', version: '0.2.18' }, + capabilities: [], + limits: { maxFrameBytes: 16 * 1024 * 1024, eventBufferCapacity: 1024 }, + }, + }), + } as MessageEvent); + await Promise.resolve(); + const syncEvents = this.sent[1]; + this.onmessage?.({ + data: JSON.stringify({ + jsonrpc: '2.0', + id: syncEvents.id, + result: validSyncEventsResult(), + }), + } as MessageEvent); + } + closeFromServer(): void { this.readyState = 3; this.onclose?.({} as CloseEvent); @@ -380,7 +1484,9 @@ describe('WebSocketTransportAdapter reconnect lifecycle', () => { this.onclose?.({} as CloseEvent); } - send(): void {} + send(payload: string): void { + this.sent.push(JSON.parse(payload) as Record); + } } vi.stubGlobal('WebSocket', MockWebSocket); @@ -388,6 +1494,7 @@ describe('WebSocketTransportAdapter reconnect lifecycle', () => { const connection = adapter.connect(); sockets[0].open(); + await sockets[0].completeInitialize(); await connection; sockets[0].closeFromServer(); expect(vi.getTimerCount()).toBe(1); diff --git a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts index 6351becbd1..38be989fde 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts @@ -2,6 +2,8 @@ import { ITransportAdapter } from './base'; import { createLogger } from '@/shared/utils/logger'; +import { getVersionInfo } from '@/shared/utils/version'; +import { projectAgenticEventEnvelope } from '@/shared/utils/agenticEventProjection'; import type { AgentSessionArchiveStateRequest, AgentSessionForkAtTurnRequest, @@ -23,6 +25,13 @@ import type { } from '@/generated/api'; const log = createLogger('WebSocketAdapter'); +const APP_SERVER_PROTOCOL_VERSION = 4; +const APP_SERVER_INITIALIZE_METHOD = 'app/initialize'; +const APP_SERVER_INITIALIZE_CAPABILITY = 'app.initialize'; +const APP_SERVER_SYNC_EVENTS_METHOD = 'app/syncEvents'; +export const SESSION_OWNER_REFRESH_EVENT = 'app-server://session-owner-refresh-required'; +const SESSION_OWNER_REFRESH_ERROR = + 'App Server state or a side-effect outcome cannot be recovered safely. Reload the Web page before retrying.'; /** * Typed mapping from the frontend's snake_case agent commands to the app-server @@ -55,26 +64,30 @@ interface AgentCommandEntry { method: string; request?: unknown; response?: unknown; + sideEffecting?: true; } export const AGENT_COMMAND_SCHEMA = { - create_session: { method: 'agent/createSession' }, + create_session: { method: 'agent/createSession', sideEffecting: true }, list_sessions: { method: 'agent/listSessions', response: null as unknown as ListSessionsResponse, }, - delete_session: { method: 'agent/deleteSession' }, + delete_session: { method: 'agent/deleteSession', sideEffecting: true }, fork_session: { method: 'session/forkAtTurn', + sideEffecting: true, request: null as unknown as AgentSessionForkAtTurnRequest, response: null as unknown as ForkSessionResponse, }, archive_session: { method: 'session/setArchived', + sideEffecting: true, request: null as unknown as AgentSessionArchiveStateRequest, }, unarchive_session: { method: 'session/setArchived', + sideEffecting: true, request: null as unknown as AgentSessionArchiveStateRequest, }, // `start_dialog_turn` maps to `agent/submitDialogTurn`, not `agent/submitTurn`: @@ -84,24 +97,28 @@ export const AGENT_COMMAND_SCHEMA = { // Tauri command (`agentic_api.rs`). start_dialog_turn: { method: 'agent/submitDialogTurn', + sideEffecting: true, request: null as unknown as SubmitDialogTurnBody, response: null as unknown as SubmitDialogTurnResponse, }, cancel_dialog_turn: { method: 'agent/cancelTurn', + sideEffecting: true, response: null as unknown as RunResponse, }, // Permission surface: the reply/list/grants operations map to the app-server // permission methods. `subscribe_permission_requests` has no direct - // counterpart -- in web mode the app-server pushes `permission://event` - // notifications over the same connection, so subscribing is satisfied by - // fetching the current pending set once. + // counterpart -- in web mode the app-server pushes typed + // `agent/permissionEvent` notifications, which this adapter reconciles with + // `app/syncEvents` and projects to `permission://event`. respond_permission: { method: 'agent/respondPermission', + sideEffecting: true, request: null as unknown as { request_id: string; reply: PermissionReply }, }, respond_permission_batch: { method: 'agent/respondPermissionBatch', + sideEffecting: true, request: null as unknown as { request_id: string; reply: PermissionReply }, }, list_pending_permission_requests: { @@ -117,11 +134,13 @@ export const AGENT_COMMAND_SCHEMA = { }, remove_project_permission_grant: { method: 'agent/removeProjectPermissionGrant', + sideEffecting: true, request: null as unknown as PermissionGrant, response: null as unknown as RemoveProjectPermissionGrantResponse, }, clear_project_permission_grants: { method: 'agent/clearProjectPermissionGrants', + sideEffecting: true, request: null as unknown as { project_id: string }, }, // Git service surface (read-only in this batch). The app-server schema uses @@ -163,21 +182,23 @@ export const AGENT_COMMAND_SCHEMA = { get_configs: { method: 'config/getConfigs' }, set_agent_profile_config: { method: 'config/setAgentProfileConfig', + sideEffecting: true, request: null as unknown as SetAgentProfileConfigMessage, response: null as unknown as SetAgentProfileConfigResponse, }, reset_agent_profile_config: { method: 'config/resetAgentProfileConfig', + sideEffecting: true, request: null as unknown as ResetAgentProfileConfigMessage, response: null as unknown as ResetAgentProfileConfigResponse, }, - set_config: { method: 'config/setConfig' }, - save_cloud_speech_config: { method: 'config/saveCloudSpeechConfig' }, + set_config: { method: 'config/setConfig', sideEffecting: true }, + save_cloud_speech_config: { method: 'config/saveCloudSpeechConfig', sideEffecting: true }, validate_config: { method: 'config/validateConfig' }, i18n_get_current_language: { method: 'i18n/getCurrentLanguage' }, - i18n_set_language: { method: 'i18n/setLanguage' }, + i18n_set_language: { method: 'i18n/setLanguage', sideEffecting: true }, i18n_get_config: { method: 'i18n/getConfig' }, - i18n_set_config: { method: 'i18n/setConfig' }, + i18n_set_config: { method: 'i18n/setConfig', sideEffecting: true }, i18n_get_supported_languages: { method: 'i18n/getSupportedLanguages' }, } as const satisfies Record; @@ -346,6 +367,128 @@ interface PendingRequest { resolve: (value: any) => void; reject: (error: any) => void; timeout: NodeJS.Timeout; + socket: WebSocket; + timeoutLabel: string; + outcomeUnknownOnDisconnect: boolean; +} + +interface AppServerSyncEventsResponse { + cursors: unknown[]; + pendingPermissions: Array>; + agentSnapshotAvailable: boolean; + configSnapshotAvailable: boolean; + externalSourceSnapshotAvailable: boolean; +} + +interface SessionOwnerRefreshState { + generation: number; + stream: 'agent' | 'app'; + reason: + | 'agent_snapshot_unavailable' + | 'event_stream_invalidated' + | 'side_effect_outcome_unknown'; + resyncMethod: string; +} + +interface BufferedPermissionEvent { + connectionId: string; + sequence: number; + event: unknown; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} + +function isCapabilityDescriptor(value: unknown): boolean { + if (!isRecord(value) || typeof value.id !== 'string' || value.id.trim() === '') { + return false; + } + const availability = value.availability; + if ( + !isRecord(availability) + || (availability.availability !== 'available' && availability.availability !== 'unavailable') + ) { + return false; + } + if ( + availability.availability === 'unavailable' + && typeof availability.reason !== 'string' + ) { + return false; + } + return value.methods === undefined + || (Array.isArray(value.methods) && value.methods.every(method => typeof method === 'string')); +} + +class AppServerProtocolNegotiationError extends Error {} + +class SessionOwnerRefreshRequiredError extends AppServerProtocolNegotiationError {} + +function validateInitializeResponse(value: unknown): { maxFrameBytes: number } { + if (!isRecord(value)) { + throw new AppServerProtocolNegotiationError('Invalid App Server protocol version response'); + } + + const protocolVersion = value.protocolVersion; + const minimumProtocolVersion = value.minimumProtocolVersion; + if ( + !isPositiveSafeInteger(protocolVersion) + || !isPositiveSafeInteger(minimumProtocolVersion) + || minimumProtocolVersion > protocolVersion + || APP_SERVER_PROTOCOL_VERSION < minimumProtocolVersion + || APP_SERVER_PROTOCOL_VERSION > protocolVersion + ) { + throw new AppServerProtocolNegotiationError( + `Incompatible App Server protocol version: client=${APP_SERVER_PROTOCOL_VERSION}, minimum=${String(minimumProtocolVersion)}, current=${String(protocolVersion)}`, + ); + } + + if (!Array.isArray(value.capabilities) || !value.capabilities.every(isCapabilityDescriptor)) { + throw new AppServerProtocolNegotiationError('Invalid App Server capabilities response'); + } + + const limits = value.limits; + if ( + !isRecord(limits) + || !isPositiveSafeInteger(limits.maxFrameBytes) + || !isPositiveSafeInteger(limits.eventBufferCapacity) + ) { + throw new AppServerProtocolNegotiationError('Invalid App Server transport limits response'); + } + return { maxFrameBytes: limits.maxFrameBytes as number }; +} + +function validateSyncEventsResponse(value: unknown): AppServerSyncEventsResponse { + if ( + !isRecord(value) + || !Array.isArray(value.cursors) + || !Array.isArray(value.pendingPermissions) + || !value.pendingPermissions.every(isRecord) + || typeof value.agentSnapshotAvailable !== 'boolean' + || typeof value.configSnapshotAvailable !== 'boolean' + || typeof value.externalSourceSnapshotAvailable !== 'boolean' + ) { + throw new AppServerProtocolNegotiationError('Invalid App Server event synchronization response'); + } + return value as unknown as AppServerSyncEventsResponse; +} + +function isPermanentInitializeFailure(error: unknown): boolean { + if (error instanceof AppServerProtocolNegotiationError) { + return true; + } + + const data = error instanceof Error + ? (error as Error & { data?: unknown }).data + : undefined; + return isRecord(data) + && data.capability === APP_SERVER_INITIALIZE_CAPABILITY + && data.retryable === false; } export function webSocketResponseError(value: unknown): Error { @@ -368,16 +511,30 @@ export interface DecodedWsNotification { /** Project one app-server JSON-RPC notification into the frontend event bus. */ export function decodeWsNotification(message: any): DecodedWsNotification | null { - if (message?.method === 'agent/frontendEvent' && message.params?.event) { + if (message?.method === 'agent/event' && message.params?.event) { + const projected = projectAgenticEventEnvelope(message.params.event); + if (!projected) return null; + return { + event: projected.eventName, + payload: projected.payload, + }; + } + if (message?.method === 'agent/permissionEvent' && message.params?.event) { return { - event: message.params.event, - payload: message.params.payload, + event: 'permission://event', + payload: message.params.event, }; } - if (message?.method === 'config/event' && message.params) { + if (message?.method === 'config/event' && message.params?.event) { return { event: 'config://updated', - payload: message.params as ConfigUpdate, + payload: message.params.event as ConfigUpdate, + }; + } + if (message?.method === 'app/eventStreamState' && message.params) { + return { + event: 'app-server://event-stream-state', + payload: message.params, }; } return null; @@ -392,6 +549,16 @@ export class WebSocketTransportAdapter implements ITransportAdapter { private reconnectAttempts = 0; private maxReconnectAttempts = 5; private reconnectDelay = 1000; + private protocolReady = false; + private connectionGeneration = 0; + private activeGeneration = 0; + private sessionOwnerRefreshRequired: SessionOwnerRefreshState | null = null; + private maxFrameBytes: number | null = null; + private pendingPermissionRequests = new Map>(); + private permissionSyncPromise: Promise | null = null; + private permissionSyncSocket: WebSocket | null = null; + private permissionSyncDirty = false; + private bufferedPermissionEvents: BufferedPermissionEvent[] = []; // Held so `disconnect()` can cancel a reconnect that is already scheduled; without it a // pending timer reopens the socket after an explicit teardown. private reconnectTimer: ReturnType | null = null; @@ -403,20 +570,75 @@ export class WebSocketTransportAdapter implements ITransportAdapter { async connect(): Promise { + this.assertSessionOwnerRefreshNotRequired(); + if (this.protocolReady && this.ws?.readyState === WebSocket.OPEN) { + return; + } + if (this.connectPromise) { + return this.connectPromise; + } + + const attempt = this.connectAndInitialize(); + this.connectPromise = attempt; + try { + await attempt; + } finally { + if (this.connectPromise === attempt) { + this.connectPromise = null; + } + } + } + + private async connectAndInitialize(): Promise { return new Promise((resolve, reject) => { try { log.info('Connecting', { url: this.url }); const ws = new WebSocket(this.url); this.ws = ws; + this.protocolReady = false; + this.maxFrameBytes = null; let settled = false; + let reconnectOnClose = true; - ws.onopen = () => { - log.info('Connected successfully'); - this.reconnectAttempts = 0; - this.setupMessageHandler(); - if (!settled) { + ws.onopen = async () => { + this.setupMessageHandler(ws); + try { + const initialized = await this.sendJsonRpcRequest(APP_SERVER_INITIALIZE_METHOD, { + protocolVersion: APP_SERVER_PROTOCOL_VERSION, + client: { + name: 'bitfun-web-ui', + version: getVersionInfo().version, + }, + }, APP_SERVER_INITIALIZE_METHOD); + const negotiated = validateInitializeResponse(initialized); + this.maxFrameBytes = negotiated.maxFrameBytes; + if (settled) return; + const generation = this.connectionGeneration + 1; + this.activeGeneration = generation; + const synchronized = await this.synchronizeEvents(); + if (settled) return; + this.connectionGeneration = generation; + if (generation > 1 && !synchronized.agentSnapshotAvailable) { + this.markSessionOwnerRefreshRequired({ + generation, + stream: 'agent', + reason: 'agent_snapshot_unavailable', + resyncMethod: 'session/sync', + }); + throw new SessionOwnerRefreshRequiredError(SESSION_OWNER_REFRESH_ERROR); + } + this.protocolReady = true; + this.reconnectAttempts = 0; settled = true; + log.info('Connected successfully'); resolve(); + } catch (error) { + if (settled) return; + settled = true; + this.protocolReady = false; + reconnectOnClose = !isPermanentInitializeFailure(error); + reject(error); + ws.close(); } }; @@ -430,11 +652,19 @@ export class WebSocketTransportAdapter implements ITransportAdapter { ws.onclose = () => { log.info('Connection closed'); + this.resetPermissionSyncForSocket(ws); + this.rejectPendingRequestsForSocket(ws); + if (this.ws !== ws) { + return; + } + this.protocolReady = false; if (!settled) { settled = true; - reject(new Error('WebSocket connection closed before open')); + reject(new Error('WebSocket connection closed before protocol initialization')); + } + if (reconnectOnClose) { + this.handleDisconnect(); } - this.handleDisconnect(); }; } catch (error) { log.error('Failed to create WebSocket', error); @@ -447,15 +677,264 @@ export class WebSocketTransportAdapter implements ITransportAdapter { private connectPromise: Promise | null = null; private async ensureConnected(): Promise { - if (this.ws && this.ws.readyState === WebSocket.OPEN) return; + if (this.protocolReady && this.ws?.readyState === WebSocket.OPEN) { + await this.permissionSyncPromise; + this.assertBusinessReady(); + return; + } + await this.connect(); + await this.permissionSyncPromise; + this.assertBusinessReady(); + } - if (!this.connectPromise) { - this.connectPromise = this.connect().finally(() => { - this.connectPromise = null; + private assertBusinessReady(): void { + this.assertSessionOwnerRefreshNotRequired(); + } + + private assertSessionOwnerRefreshNotRequired(): void { + if (this.sessionOwnerRefreshRequired) { + throw new SessionOwnerRefreshRequiredError(SESSION_OWNER_REFRESH_ERROR); + } + } + + private async synchronizeEvents( + streams: Array<'agent' | 'permission' | 'config'> = ['agent', 'permission', 'config'], + ): Promise { + if (!streams.includes('permission')) { + return this.requestEventSynchronization(streams); + } + const socket = this.ws; + if (!socket || socket.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not open'); + } + if (this.permissionSyncPromise && this.permissionSyncSocket === socket) { + this.permissionSyncDirty = true; + return this.permissionSyncPromise; + } + + const attempt = (async () => { + let response: AppServerSyncEventsResponse | null = null; + let requestedStreams = streams; + do { + this.permissionSyncDirty = false; + response = await this.requestEventSynchronization(requestedStreams); + this.reconcilePermissionSnapshotAtWatermark(response); + requestedStreams = ['permission']; + } while (this.permissionSyncDirty); + return response; + })(); + this.permissionSyncPromise = attempt; + this.permissionSyncSocket = socket; + try { + return await attempt; + } finally { + if (this.permissionSyncPromise === attempt) { + this.permissionSyncPromise = null; + this.permissionSyncSocket = null; + } + } + } + + private resetPermissionSyncForSocket(socket: WebSocket): void { + if (this.permissionSyncSocket !== socket) { + return; + } + this.permissionSyncPromise = null; + this.permissionSyncSocket = null; + this.permissionSyncDirty = false; + this.bufferedPermissionEvents = []; + } + + private rejectPendingRequestsForSocket(socket: WebSocket): void { + let sideEffectOutcomeUnknown = false; + for (const [requestId, pending] of this.pendingRequests) { + if (pending.socket !== socket) { + continue; + } + clearTimeout(pending.timeout); + this.pendingRequests.delete(requestId); + const message = pending.outcomeUnknownOnDisconnect + ? `outcome_unknown: ${pending.timeoutLabel} may have been applied before the App Server connection closed; reload authoritative state before retrying` + : `WebSocket disconnected before response: ${pending.timeoutLabel}`; + sideEffectOutcomeUnknown ||= pending.outcomeUnknownOnDisconnect; + pending.reject(new Error(message)); + } + if (sideEffectOutcomeUnknown && this.ws === socket) { + this.markSideEffectOutcomeUnknown(); + } + } + + private async requestEventSynchronization( + streams: Array<'agent' | 'permission' | 'config'>, + ): Promise { + return validateSyncEventsResponse( + await this.sendJsonRpcRequest(APP_SERVER_SYNC_EVENTS_METHOD, { + streams, + }, APP_SERVER_SYNC_EVENTS_METHOD), + ); + } + + private reconcilePermissionSnapshotAtWatermark( + response: AppServerSyncEventsResponse, + ): void { + const cursor = response.cursors.find(value => ( + isRecord(value) + && value.stream === 'permission' + && typeof value.connectionId === 'string' + && value.connectionId.length > 0 + && typeof value.sequence === 'number' + && Number.isSafeInteger(value.sequence) + && value.sequence >= 0 + )) as Record | undefined; + if (!cursor) { + throw new AppServerProtocolNegotiationError( + 'Permission synchronization response is missing its authoritative cursor', + ); + } + + const connectionId = cursor.connectionId as string; + const watermark = cursor.sequence as number; + this.reconcilePermissionSnapshot(response.pendingPermissions); + const buffered = this.bufferedPermissionEvents; + this.bufferedPermissionEvents = []; + for (const entry of buffered) { + if (entry.connectionId !== connectionId) { + throw new AppServerProtocolNegotiationError( + 'Permission event belongs to a different App Server connection', + ); + } + if (entry.sequence > watermark) { + this.handlePermissionEvent(entry.event); + } + } + } + + private bufferPermissionEvent(cursor: unknown, event: unknown): boolean { + if ( + !isRecord(cursor) + || cursor.stream !== 'permission' + || typeof cursor.connectionId !== 'string' + || cursor.connectionId.length === 0 + || typeof cursor.sequence !== 'number' + || !Number.isSafeInteger(cursor.sequence) + || cursor.sequence < 0 + ) { + return false; + } + this.bufferedPermissionEvents.push({ + connectionId: cursor.connectionId, + sequence: cursor.sequence, + event, + }); + return true; + } + + private resynchronizePermissions(): void { + const socket = this.ws; + void this.synchronizeEvents(['permission']).catch(error => { + log.error('Failed to resynchronize permission events', error); + if (this.ws === socket && socket?.readyState === WebSocket.OPEN) { + socket?.close(); + } + }); + } + + private reconcilePermissionSnapshot( + pendingPermissions: Array>, + ): void { + const next = new Map>(); + for (const request of pendingPermissions) { + if (typeof request.requestId === 'string' && request.requestId) { + next.set(request.requestId, request); + } + } + + for (const requestId of this.pendingPermissionRequests.keys()) { + if (next.has(requestId)) continue; + this.emitEvent('permission://event', { + event: 'cancelled', + requestId, + reason: 'Permission request is absent from the authoritative reconnect snapshot', }); } - return this.connectPromise; + for (const [requestId, request] of next) { + const alreadyPending = this.pendingPermissionRequests.has(requestId); + if (!alreadyPending) { + this.emitEvent('permission://event', { event: 'asked', request }); + } + } + this.pendingPermissionRequests = next; + } + + private handlePermissionEvent(value: unknown): boolean { + if (!isRecord(value) || typeof value.event !== 'string') return false; + if (value.event === 'asked') { + if (!isRecord(value.request) || typeof value.request.requestId !== 'string') return false; + const requestId = value.request.requestId; + const alreadyPending = this.pendingPermissionRequests.has(requestId); + this.pendingPermissionRequests.set(requestId, value.request); + if (!alreadyPending) this.emitEvent('permission://event', value); + return true; + } + if ( + (value.event === 'replied' || value.event === 'cancelled') + && typeof value.requestId === 'string' + ) { + const requestId = value.requestId; + if (!this.pendingPermissionRequests.has(requestId)) return true; + this.pendingPermissionRequests.delete(requestId); + this.emitEvent('permission://event', value); + return true; + } + return false; + } + + private markSessionOwnerRefreshRequired(state: SessionOwnerRefreshState): void { + if ( + this.sessionOwnerRefreshRequired?.generation === state.generation + && this.sessionOwnerRefreshRequired.reason === state.reason + ) { + return; + } + this.sessionOwnerRefreshRequired = state; + this.emitEvent(SESSION_OWNER_REFRESH_EVENT, state); + } + + private markSideEffectOutcomeUnknown(): void { + if (this.sessionOwnerRefreshRequired) { + return; + } + this.markSessionOwnerRefreshRequired({ + generation: this.activeGeneration, + stream: 'app', + reason: 'side_effect_outcome_unknown', + resyncMethod: 'reload', + }); + } + + private closeForSessionOwnerRefresh(state: SessionOwnerRefreshState): void { + this.markSessionOwnerRefreshRequired(state); + this.protocolReady = false; + const error = new SessionOwnerRefreshRequiredError(SESSION_OWNER_REFRESH_ERROR); + this.pendingRequests.forEach(pending => { + clearTimeout(pending.timeout); + pending.reject(error); + }); + this.pendingRequests.clear(); + this.ws?.close(); + } + + private emitEvent(eventName: string, payload: unknown): void { + const listeners = this.eventListeners.get(eventName); + if (!listeners || listeners.size === 0) return; + listeners.forEach(callback => { + try { + callback(payload); + } catch (error) { + log.error('Error in event listener', { event: eventName, error }); + } + }); } private clearReconnectTimer(): void { @@ -466,6 +945,10 @@ export class WebSocketTransportAdapter implements ITransportAdapter { } private handleDisconnect(): void { + if (this.sessionOwnerRefreshRequired) { + log.error('Automatic reconnect blocked until the Web page reloads Session owner state'); + return; + } if (this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; const delay = this.reconnectDelay * this.reconnectAttempts; @@ -491,10 +974,11 @@ export class WebSocketTransportAdapter implements ITransportAdapter { } - private setupMessageHandler(): void { - if (!this.ws) return; - - this.ws.onmessage = (event) => { + private setupMessageHandler(ws: WebSocket): void { + ws.onmessage = (event) => { + if (this.ws !== ws) { + return; + } try { const message = JSON.parse(event.data); @@ -502,6 +986,9 @@ export class WebSocketTransportAdapter implements ITransportAdapter { // Shape: `{ jsonrpc: "2.0", id, result | error }`. if (message.id !== undefined && this.pendingRequests.has(message.id)) { const pending = this.pendingRequests.get(message.id)!; + if (pending.socket !== ws) { + return; + } clearTimeout(pending.timeout); if (message.error) { @@ -515,23 +1002,47 @@ export class WebSocketTransportAdapter implements ITransportAdapter { } // JSON-RPC 2.0 notification: `{ jsonrpc: "2.0", method, params }`. - // The server pushes `agent/frontendEvent` notifications carrying the - // projected frontend event name and payload, so dispatch on - // `params.event` exactly like the legacy `WsMessage::Event{event,payload}`. + if (message.method === 'agent/permissionEvent') { + if (this.permissionSyncPromise) { + if (!this.bufferPermissionEvent(message.params?.cursor, message.params?.event)) { + ws.close(); + } + return; + } + this.handlePermissionEvent(message.params?.event); + return; + } + if ( + message.method === 'app/eventStreamState' + && message.params?.stream === 'agent' + && message.params?.resync?.snapshotAvailable === false + ) { + this.closeForSessionOwnerRefresh({ + generation: this.activeGeneration, + stream: 'agent', + reason: 'event_stream_invalidated', + resyncMethod: typeof message.params.resync.method === 'string' + ? message.params.resync.method + : 'session/sync', + }); + } else if ( + message.method === 'app/eventStreamState' + && message.params?.stream === 'permission' + && (message.params?.state === 'lagged' || message.params?.state === 'invalidated') + && message.params?.resync?.method === APP_SERVER_SYNC_EVENTS_METHOD + && message.params?.resync?.snapshotAvailable === true + ) { + this.resynchronizePermissions(); + } else if ( + message.method === 'app/eventStreamState' + && message.params?.stream === 'permission' + && (message.params?.state === 'closed' || message.params?.resync?.snapshotAvailable !== true) + ) { + ws.close(); + } const notification = decodeWsNotification(message); if (notification) { - const eventName = notification.event; - const payload = notification.payload; - const listeners = this.eventListeners.get(eventName); - if (listeners && listeners.size > 0) { - listeners.forEach(callback => { - try { - callback(payload); - } catch (error) { - log.error('Error in event listener', { event: eventName, error }); - } - }); - } + this.emitEvent(notification.event, notification.payload); return; } @@ -577,11 +1088,8 @@ export class WebSocketTransportAdapter implements ITransportAdapter { (typeof AGENT_COMMAND_SCHEMA)[K] extends { response: infer S } ? S : unknown >; async request(action: string, params?: any): Promise { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - await this.ensureConnected(); - } + await this.ensureConnected(); - const messageId = `msg_${Date.now()}_${++this.messageIdCounter}`; // Translate the frontend snake_case command to the app-server JSON-RPC // method so agent-kernel requests reach the app-server surface in web mode. const method = resolveWsMethod(action); @@ -598,25 +1106,78 @@ export class WebSocketTransportAdapter implements ITransportAdapter { // desktop host does in Rust (`agentic_api.rs`). const body = encodeRequestBody(action, envelope); + const command = (AGENT_COMMAND_SCHEMA as Record)[action]; + return this.sendJsonRpcRequest( + method, + body, + action, + value => decodeResponseBody(action, value), + command?.sideEffecting === true, + ); + } + + private sendJsonRpcRequest( + method: string, + params: unknown, + timeoutLabel: string, + project: (value: any) => T = value => value as T, + outcomeUnknownOnDisconnect = false, + ): Promise { return new Promise((resolve, reject) => { + const ws = this.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) { + reject(new Error('WebSocket is not open')); + return; + } + + const messageId = `msg_${Date.now()}_${++this.messageIdCounter}`; + let payload: string; + try { + payload = JSON.stringify({ + jsonrpc: '2.0', + id: messageId, + method, + params, + }); + } catch (error) { + reject(error); + return; + } + const payloadBytes = new TextEncoder().encode(payload).byteLength; + if (this.maxFrameBytes !== null && payloadBytes > this.maxFrameBytes) { + const error = new Error( + `Request ${timeoutLabel} is ${payloadBytes} bytes and exceeds negotiated App Server frame limit ${this.maxFrameBytes} bytes`, + ) as Error & { code?: string; maxFrameBytes?: number; payloadBytes?: number }; + error.code = 'payload_too_large'; + error.maxFrameBytes = this.maxFrameBytes; + error.payloadBytes = payloadBytes; + reject(error); + return; + } const timeout = setTimeout(() => { this.pendingRequests.delete(messageId); - reject(new Error(`Request timeout: ${action}`)); + const message = outcomeUnknownOnDisconnect + ? `outcome_unknown: ${timeoutLabel} may have been applied before its App Server response timed out; reload authoritative state before retrying` + : `Request timeout: ${timeoutLabel}`; + reject(new Error(message)); + if (outcomeUnknownOnDisconnect && this.ws === ws) { + this.markSideEffectOutcomeUnknown(); + this.protocolReady = false; + ws.close(); + } }, 30000); this.pendingRequests.set(messageId, { - resolve: (value: any) => resolve(decodeResponseBody(action, value)), + resolve: (value: any) => resolve(project(value)), reject, timeout, + socket: ws, + timeoutLabel, + outcomeUnknownOnDisconnect, }); try { - this.ws!.send(JSON.stringify({ - jsonrpc: '2.0', - id: messageId, - method, - params: body, - })); + ws.send(payload); } catch (error) { clearTimeout(timeout); this.pendingRequests.delete(messageId); @@ -633,6 +1194,13 @@ export class WebSocketTransportAdapter implements ITransportAdapter { const listeners = this.eventListeners.get(event)!; listeners.add(callback); + if (event === SESSION_OWNER_REFRESH_EVENT && this.sessionOwnerRefreshRequired) { + try { + callback(this.sessionOwnerRefreshRequired as T); + } catch (error) { + log.error('Error replaying durable event state to listener', { event, error }); + } + } return () => { @@ -671,11 +1239,24 @@ export class WebSocketTransportAdapter implements ITransportAdapter { this.reconnectAttempts = this.maxReconnectAttempts; + this.protocolReady = false; + this.connectionGeneration = 0; + this.activeGeneration = 0; + this.sessionOwnerRefreshRequired = null; + this.maxFrameBytes = null; + this.pendingPermissionRequests.clear(); + this.permissionSyncPromise = null; + this.permissionSyncSocket = null; + this.permissionSyncDirty = false; + this.bufferedPermissionEvents = []; this.connectPromise = null; } isConnected(): boolean { - return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + return this.protocolReady + && !this.sessionOwnerRefreshRequired + && this.ws !== null + && this.ws.readyState === WebSocket.OPEN; } } diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 6e4e359971..e492c1be66 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -239,9 +239,20 @@ export interface SessionViewRestoreTiming { turnLoad: SessionTurnLoadTiming; } +export interface PendingUserInputSnapshot { + toolId: string; + sessionId: string; + turnId: string; + sourceSessionId: string; + sourceTurnId: string; + registrationSequence: number; + input: unknown; +} + export interface RestoreSessionViewResponse { session: SessionInfo; turns: DialogTurnData[]; + pendingUserInputs?: PendingUserInputSnapshot[]; currentContextUsage?: SessionContextUsage | null; turnCatalog?: SessionTurnCatalog; contextRestoreState: 'ready' | 'pending'; diff --git a/src/web-ui/src/infrastructure/api/service-api/ToolAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ToolAPI.ts index 9034d0a2ad..e94afe1210 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ToolAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ToolAPI.ts @@ -64,15 +64,23 @@ export class ToolAPI { /** * Submit user answers. */ - async submitUserAnswers(toolId: string, answers: Record): Promise { + async submitUserAnswers( + identity: { + sessionId: string; + turnId: string; + toolId: string; + registrationSequence: number; + }, + answers: Record + ): Promise { try { - await api.invoke('submit_user_answers', { - toolId, + await api.invoke('submit_user_answers', { + ...identity, answers }); } catch (error) { - log.error('Failed to submit user answers', { toolId, error }); - throw createTauriCommandError('submit_user_answers', error, { toolId, answers }); + log.error('Failed to submit user answers', { toolId: identity.toolId, error }); + throw createTauriCommandError('submit_user_answers', error, { ...identity, answers }); } } } diff --git a/src/web-ui/src/locales/en-US/errors.json b/src/web-ui/src/locales/en-US/errors.json index 2cf15c2e2b..15b1fbc4c5 100644 --- a/src/web-ui/src/locales/en-US/errors.json +++ b/src/web-ui/src/locales/en-US/errors.json @@ -163,6 +163,11 @@ "core": { "initializationFailed": "Core system initialization failed" }, + "sessionOwnerRefresh": { + "title": "Reload required", + "message": "This page can no longer verify App Server state or whether an operation completed. Reload before continuing.", + "reload": "Reload page" + }, "boundary": { "title": "Application Error", "reload": "Reload", diff --git a/src/web-ui/src/locales/zh-CN/errors.json b/src/web-ui/src/locales/zh-CN/errors.json index 92d3599ca8..30b5c1f121 100644 --- a/src/web-ui/src/locales/zh-CN/errors.json +++ b/src/web-ui/src/locales/zh-CN/errors.json @@ -163,6 +163,11 @@ "core": { "initializationFailed": "Core system initialization failed" }, + "sessionOwnerRefresh": { + "title": "需要重新加载", + "message": "当前页面已无法确认 App Server 状态或某项操作是否完成。请先重新加载页面,再继续操作。", + "reload": "重新加载页面" + }, "boundary": { "title": "Application Error", "reload": "Reload", diff --git a/src/web-ui/src/locales/zh-TW/errors.json b/src/web-ui/src/locales/zh-TW/errors.json index 5967e96b20..5bebed5974 100644 --- a/src/web-ui/src/locales/zh-TW/errors.json +++ b/src/web-ui/src/locales/zh-TW/errors.json @@ -163,6 +163,11 @@ "core": { "initializationFailed": "Core system initialization failed" }, + "sessionOwnerRefresh": { + "title": "需要重新載入", + "message": "目前頁面已無法確認 App Server 狀態或某項操作是否完成。請先重新載入頁面,再繼續操作。", + "reload": "重新載入頁面" + }, "boundary": { "title": "Application Error", "reload": "Reload", diff --git a/src/web-ui/src/shared/utils/agenticEventProjection.ts b/src/web-ui/src/shared/utils/agenticEventProjection.ts new file mode 100644 index 0000000000..dee2242cc6 --- /dev/null +++ b/src/web-ui/src/shared/utils/agenticEventProjection.ts @@ -0,0 +1,86 @@ +const AGENTIC_FRONTEND_EVENT_NAMES: Readonly> = { + SessionCreated: 'agentic://session-created', + SessionDeleted: 'agentic://session-deleted', + SessionStateChanged: 'agentic://session-state-changed', + SessionHistoryChanged: 'agentic://session-history-changed', + SessionTitleGenerated: 'session_title_generated', + ImageAnalysisStarted: 'agentic://image-analysis-started', + ImageAnalysisCompleted: 'agentic://image-analysis-completed', + DialogTurnStarted: 'agentic://dialog-turn-started', + SubagentSessionLinked: 'agentic://subagent-session-linked', + ModelRoundStarted: 'agentic://model-round-started', + ModelRoundCompleted: 'agentic://model-round-completed', + ModelRoundAttemptSuperseded: 'agentic://model-round-attempt-superseded', + TextChunk: 'agentic://text-chunk', + ThinkingChunk: 'agentic://text-chunk', + ToolEvent: 'agentic://tool-event', + DialogTurnCompleted: 'agentic://dialog-turn-completed', + DialogTurnFailed: 'agentic://dialog-turn-failed', + DialogTurnCancelled: 'agentic://dialog-turn-cancelled', + TokenUsageUpdated: 'agentic://token-usage-updated', + ContextCompressionStarted: 'agentic://context-compression-started', + ContextCompressionCompleted: 'agentic://context-compression-completed', + ContextCompressionFailed: 'agentic://context-compression-failed', + ThreadGoalUpdated: 'agentic://thread-goal-updated', + DeepReviewQueueStateChanged: 'agentic://deep-review-queue-state-changed', + SessionModelAutoMigrated: 'agentic://session-model-auto-migrated', + SessionReasoningPresetAutoCleared: 'agentic://session-reasoning-preset-auto-cleared', + UserSteeringInjected: 'agentic://user-steering-injected', +}; + +function camelKey(key: string): string { + return key.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()); +} + +function projectTopLevelFields(value: Record): Record { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [camelKey(key), nested]), + ); +} + +export interface AgenticFrontendEventProjection { + eventName: string; + payload: Record; + envelopeId?: string; +} + +/** Project one authoritative AgenticEventEnvelope into the established Web event bus. */ +export function projectAgenticEventEnvelope( + envelope: unknown, +): AgenticFrontendEventProjection | null { + if (!envelope || typeof envelope !== 'object') { + return null; + } + const envelopeRecord = envelope as Record; + if (!envelopeRecord.event || typeof envelopeRecord.event !== 'object') { + return null; + } + const raw = envelopeRecord.event as Record; + const rawType = typeof raw.type === 'string' ? raw.type : ''; + const eventName = AGENTIC_FRONTEND_EVENT_NAMES[rawType]; + if (!eventName) { + return null; + } + + // Match the Rust frontend projection: only AgenticEvent variant fields are + // renamed. Nested contracts such as ToolEventData, tool params, metadata, + // token details, and error detail remain owned by their own serde schema. + const payload = projectTopLevelFields(raw); + delete payload.type; + if (rawType === 'ThinkingChunk') { + payload.text = payload.content; + payload.contentType = 'thinking'; + payload.isThinkingEnd = payload.isEnd; + delete payload.content; + delete payload.isEnd; + } + if (rawType === 'SessionTitleGenerated' && payload.timestamp === undefined) { + payload.timestamp = Date.now(); + } + + return { + eventName, + payload, + envelopeId: typeof envelopeRecord.id === 'string' ? envelopeRecord.id : undefined, + }; +}