From 38e396a6c5a33b6528889ecdb633a3141ad15075 Mon Sep 17 00:00:00 2001 From: weishao Date: Tue, 4 Aug 2026 20:14:56 +0800 Subject: [PATCH 1/2] feat(cli): add app server to cli --- .gitignore | 1 + Cargo.lock | 31 + Cargo.toml | 2 + .../agent-runtime-deployment-design.md | 223 +++- docs/architecture/app-server-architecture.md | 394 ++++++ docs/architecture/product-architecture.md | 116 +- ...tui-app-server-decoupling-refactor-plan.md | 273 ++++ scripts/check-core-boundaries.test.mjs | 1 + scripts/core-boundaries/checker.mjs | 2 + .../core-boundaries/rules/crate-layout.mjs | 2 + scripts/core-boundaries/rules/crate-rules.mjs | 53 + .../rules/source/forbidden-rules.mjs | 25 +- .../rules/tui-boundary-rules.mjs | 92 ++ .../core-boundaries/tui-boundary-ratchet.mjs | 68 + src/apps/cli/AGENTS.md | 15 +- src/apps/cli/Cargo.toml | 4 + .../cli/src/agent/context_reload_client.rs | 212 --- src/apps/cli/src/agent/mod.rs | 2 +- src/apps/cli/src/agent/runtime_client.rs | 13 +- src/apps/cli/src/agent/tui_client.rs | 1151 +++++++++++++++++ src/apps/cli/src/embedded_app_server.rs | 142 ++ src/apps/cli/src/main.rs | 95 +- src/apps/cli/src/modes/chat.rs | 33 +- src/apps/cli/src/modes/chat/capabilities.rs | 2 +- src/apps/cli/src/modes/chat/run.rs | 14 +- src/apps/cli/src/modes/chat/selection.rs | 2 +- .../cli/src/modes/chat/session_lineage.rs | 4 +- src/apps/cli/src/modes/chat/tests.rs | 6 +- src/apps/cli/src/modes/exec/lifecycle.rs | 25 +- src/apps/cli/src/runtime/mod.rs | 6 +- src/apps/cli/src/shared_runtime.rs | 48 +- src/apps/cli/src/shared_tui_backend.rs | 937 ++++++++++++++ src/apps/cli/src/tui_backend.rs | 420 ++++++ src/apps/cli/src/ui/startup.rs | 16 +- .../product_assembly_cli.rs | 93 +- .../adapters/agent-runtime-ipc/src/lib.rs | 4 +- .../agent-runtime-ipc/src/operation.rs | 62 +- .../agent-runtime-ipc/src/protocol.rs | 2 +- .../src/tests/protocol_contracts.rs | 8 +- .../src/tests/shared_controller.rs | 1 + .../assembly/core/src/product_runtime.rs | 19 +- src/crates/contracts/runtime-ports/src/lib.rs | 32 +- .../execution/agent-runtime/src/runtime.rs | 33 +- src/crates/execution/agent-runtime/src/sdk.rs | 50 +- .../interfaces/app-server-client/Cargo.toml | 18 + .../interfaces/app-server-client/src/lib.rs | 448 +++++++ .../interfaces/app-server-protocol/Cargo.toml | 26 + .../interfaces/app-server-protocol/src/app.rs | 113 ++ .../app-server-protocol/src/error.rs | 72 ++ .../app-server-protocol/src/event.rs | 134 ++ .../interfaces/app-server-protocol/src/lib.rs | 21 + .../app-server-protocol/src/method.rs | 39 + .../app-server-protocol/src/role.rs | 80 ++ .../app-server-protocol/src/transport.rs | 8 + .../interfaces/app-server-protocol/src/tui.rs | 321 +++++ src/crates/interfaces/app-server/AGENTS-CN.md | 94 +- src/crates/interfaces/app-server/AGENTS.md | 124 +- src/crates/interfaces/app-server/Cargo.toml | 26 +- src/crates/interfaces/app-server/src/agent.rs | 20 +- .../interfaces/app-server/src/client.rs | 212 ++- src/crates/interfaces/app-server/src/role.rs | 107 +- .../interfaces/app-server/src/schema.rs | 767 ----------- .../interfaces/app-server/src/schema/agent.rs | 234 ++++ .../interfaces/app-server/src/schema/app.rs | 3 + .../app-server/src/schema/config.rs | 106 ++ .../app-server/src/schema/events.rs | 116 ++ .../interfaces/app-server/src/schema/git.rs | 47 + .../interfaces/app-server/src/schema/i18n.rs | 79 ++ .../interfaces/app-server/src/schema/mod.rs | 23 + .../app-server/src/schema/permission.rs | 95 ++ .../app-server/src/schema/session.rs | 209 +++ .../interfaces/app-server/src/server.rs | 777 ++--------- .../app-server/src/server/event_forwarder.rs | 141 ++ .../app-server/src/server/fallback.rs | 38 + .../app-server/src/server/handlers/agent.rs | 111 ++ .../app-server/src/server/handlers/app.rs | 184 +++ .../app-server/src/server/handlers/config.rs | 136 ++ .../app-server/src/server/handlers/git.rs | 55 + .../app-server/src/server/handlers/i18n.rs | 114 ++ .../app-server/src/server/handlers/mod.rs | 10 + .../src/server/handlers/permission.rs | 108 ++ .../app-server/src/server/handlers/session.rs | 152 +++ .../app-server/src/server/handlers/tui.rs | 424 ++++++ .../interfaces/app-server/src/transport.rs | 22 +- .../app-server/tests/agent_kernel.rs | 1118 +++++++++++++++- src/web-ui/scripts/gen-api-barrel.mjs | 10 +- .../api/adapters/websocket-adapter.test.ts | 76 +- .../api/adapters/websocket-adapter.ts | 94 +- 88 files changed, 9560 insertions(+), 2286 deletions(-) create mode 100644 docs/architecture/app-server-architecture.md create mode 100644 docs/plans/tui-app-server-decoupling-refactor-plan.md create mode 100644 scripts/core-boundaries/rules/tui-boundary-rules.mjs create mode 100644 scripts/core-boundaries/tui-boundary-ratchet.mjs delete mode 100644 src/apps/cli/src/agent/context_reload_client.rs create mode 100644 src/apps/cli/src/agent/tui_client.rs create mode 100644 src/apps/cli/src/embedded_app_server.rs create mode 100644 src/apps/cli/src/shared_tui_backend.rs create mode 100644 src/apps/cli/src/tui_backend.rs create mode 100644 src/crates/interfaces/app-server-client/Cargo.toml create mode 100644 src/crates/interfaces/app-server-client/src/lib.rs create mode 100644 src/crates/interfaces/app-server-protocol/Cargo.toml create mode 100644 src/crates/interfaces/app-server-protocol/src/app.rs create mode 100644 src/crates/interfaces/app-server-protocol/src/error.rs create mode 100644 src/crates/interfaces/app-server-protocol/src/event.rs create mode 100644 src/crates/interfaces/app-server-protocol/src/lib.rs create mode 100644 src/crates/interfaces/app-server-protocol/src/method.rs create mode 100644 src/crates/interfaces/app-server-protocol/src/role.rs create mode 100644 src/crates/interfaces/app-server-protocol/src/transport.rs create mode 100644 src/crates/interfaces/app-server-protocol/src/tui.rs delete mode 100644 src/crates/interfaces/app-server/src/schema.rs create mode 100644 src/crates/interfaces/app-server/src/schema/agent.rs create mode 100644 src/crates/interfaces/app-server/src/schema/app.rs create mode 100644 src/crates/interfaces/app-server/src/schema/config.rs create mode 100644 src/crates/interfaces/app-server/src/schema/events.rs create mode 100644 src/crates/interfaces/app-server/src/schema/git.rs create mode 100644 src/crates/interfaces/app-server/src/schema/i18n.rs create mode 100644 src/crates/interfaces/app-server/src/schema/mod.rs create mode 100644 src/crates/interfaces/app-server/src/schema/permission.rs create mode 100644 src/crates/interfaces/app-server/src/schema/session.rs create mode 100644 src/crates/interfaces/app-server/src/server/event_forwarder.rs create mode 100644 src/crates/interfaces/app-server/src/server/fallback.rs create mode 100644 src/crates/interfaces/app-server/src/server/handlers/agent.rs create mode 100644 src/crates/interfaces/app-server/src/server/handlers/app.rs create mode 100644 src/crates/interfaces/app-server/src/server/handlers/config.rs create mode 100644 src/crates/interfaces/app-server/src/server/handlers/git.rs create mode 100644 src/crates/interfaces/app-server/src/server/handlers/i18n.rs create mode 100644 src/crates/interfaces/app-server/src/server/handlers/mod.rs create mode 100644 src/crates/interfaces/app-server/src/server/handlers/permission.rs create mode 100644 src/crates/interfaces/app-server/src/server/handlers/session.rs create mode 100644 src/crates/interfaces/app-server/src/server/handlers/tui.rs diff --git a/.gitignore b/.gitignore index aaeef78ec0..95806da18c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ dist-ssr # Build outputs - Rust/Tauri target/ **/target/ +/.targets/ # The deployable Rust services use the workspace lockfile for reproducible # container builds. !Cargo.lock diff --git a/Cargo.lock b/Cargo.lock index e8b458d129..8fec4b085a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,8 +878,11 @@ dependencies = [ "anyhow", "async-trait", "bitfun-agent-runtime", + "bitfun-app-server-client", + "bitfun-app-server-protocol", "bitfun-core", "bitfun-events", + "bitfun-runtime-ports", "futures", "log", "serde", @@ -889,6 +892,30 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "bitfun-app-server-client" +version = "0.2.15" +dependencies = [ + "agent-client-protocol", + "anyhow", + "bitfun-app-server-protocol", + "tokio", +] + +[[package]] +name = "bitfun-app-server-protocol" +version = "0.2.15" +dependencies = [ + "agent-client-protocol", + "bitfun-core-types", + "bitfun-events", + "bitfun-product-domains", + "bitfun-runtime-ports", + "serde", + "serde_json", + "ts-rs", +] + [[package]] name = "bitfun-claude-code-adapter" version = "0.2.15" @@ -920,7 +947,11 @@ 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", "bitfun-product-domains", "bitfun-runtime-ports", diff --git a/Cargo.toml b/Cargo.toml index 042561611f..ce1f8012d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ members = [ "src/apps/skin-market-server", "src/crates/interfaces/acp", "src/crates/interfaces/app-server", + "src/crates/interfaces/app-server-client", + "src/crates/interfaces/app-server-protocol", "src/crates/interfaces/sdk-host", "src/crates/adapters/agent-runtime-ipc", "src/crates/assembly/agent-content", diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index ea34089258..f5a05b6f9e 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -4,48 +4,80 @@ 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)。 +[`extensions/plugin-runtime-design.md`](extensions/plugin-runtime-design.md)。Rich Client 的 App Server 协议、Embedded/Shared Host +和 transport 提案见 [`app-server-architecture.md`](app-server-architecture.md)。该提案通过架构评审前,当前部署和调用路径以本文及 +已接线代码为准。 ## 1. 决策与当前状态 BitFun 只有一套 Agent Runtime 行为。`Embedded` 和 `Shared` 只描述同一套 Runtime 的物理部署方式,不是两套实现。 +### 1.1 Current request paths + ```mermaid flowchart TB - subgraph "产品入口" - GUI["Desktop GUI"] - TUI["TUI / Headless CLI"] - ACP["ACP"] - SDK["Agent SDK · SDK Host"] - Server["Server agent bootstrap"] - end - - GUI --> Adapter["同级 first-party adapters"] - TUI --> Adapter - ACP --> Adapter - SDK --> Adapter - Server --> Adapter - Adapter --> API["Agent Runtime API"] + Desktop["Desktop GUI"] --> DesktopAdapter["Desktop / Tauri adapter"] + Web["Web UI"] --> WebAS["loopback WebSocket App Server"] + TUI["Interactive TUI"] --> Backend["TuiBackend"] + Backend -->|"Embedded"| EmbeddedAS["in-process App Server"] + Backend -->|"--shared"| SharedIPC["private Runtime IPC v17"] + Other["Headless CLI · ACP · Peer Host · SDK Host"] --> Adapter["独立 first-party adapters"] + DesktopAdapter --> API["Agent Runtime API / owner ports"] + WebAS --> API + EmbeddedAS --> API + SharedIPC --> API + Adapter --> API API --> Coordinator["ConversationCoordinator"] Coordinator --> Owners["Session / Tool / Permission / MCP owners"] - Coordinator -. "local attach / mutation" .-> Ownership["CoreRuntimeOwnership"] ``` -当前代码状态必须和目标设计分开阅读: +Server bootstrap 是 composition root,不是客户端请求的第二条 Runtime 旁路: + +```mermaid +flowchart LR + Bootstrap["Server bootstrap / product assembly"] -. "constructs" .-> Host["transport + BitfunAppServer"] + Bootstrap -. "constructs" .-> Runtime["Embedded Runtime / owners"] + Runtime -. "injects Runtime API and owner ports" .-> Host +``` + +两张图中的实线表示当前业务请求,虚线只表示启动期构造与依赖注入。 + +### 1.2 Proposed Rich Client target + +```mermaid +flowchart TB + Rich["Desktop GUI · Web UI · Interactive TUI"] --> Host["Rich Client Host"] + Host --> Client["App Server client"] + Client --> Transport["Host-selected Embedded / Shared transport"] + Transport --> AppServer["App Server"] + Other["Headless CLI · ACP · Peer Host"] --> Adapter["独立 first-party adapters"] + SDK["Public Agent SDK"] --> SDKHost["SDK Host adapter"] + AppServer --> API["Agent Runtime API / owner ports"] + Adapter --> API + SDKHost --> API +``` + +该图是待评审目标,不是当前调用链。Shared App Server 只有达到 v17 的连接治理、安全、恢复、取消、限制、性能和回滚门槛后, +才可替换 compatibility transport;评审也可以决定保留 private v17 作为 Shared TUI 的物理 wire。 + +### 1.3 Current implementation facts | 范围 | 当前状态 | |---|---| -| Embedded Desktop GUI | 继续使用 Desktop 事件投影和 Tauri adapter;按实际打开的本机 workspace 延迟取得并持有 Embedded ownership,不增加后台进程 | -| Embedded TUI/Headless CLI/Peer Host | Session、Turn、Permission 和事件订阅统一通过同一个 Rust Runtime SDK(当前 preview);CLI crate 只保留第一方 adapter 和各形态自己的展示/断流策略 | +| Embedded Desktop GUI | 继续使用 Desktop 事件投影和 Tauri adapter;按实际打开的本机 workspace 延迟取得并持有 Embedded ownership,不增加后台进程;目标迁入同进程私有 App Server | +| Embedded interactive TUI | 已组装同进程私有 App Server,通过 in-memory transport、`AppServerClient` 和 `AppServerTuiBackend` 完成当前核心聊天与 Session 路径;剩余管理面继续迁移 | +| 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 | 只提供 health/info/WebSocket 外壳,未装配 Agent Runtime,因此不取得 workspace ownership;`bootstrap.rs` 仅保持 agent-enabled composition 的一致边界,不由当前入口启动 | -| Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter | +| 当前 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;默认仍是 Embedded | | Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、通用 Controller transfer 和 Session archive 同样不在当前协议中 | -因此当前交付的是一条窄的、显式启用的 Shared TUI deployment,不是通用本机 Server。具体 `EventQueue` 仍由 Core 产品装配;IPC 只把当前 TUI 必需的强类型操作和事件映射到同一个 Runtime owner,没有事件重放或公开协议承诺。 +因此当前交付的是 Embedded TUI App Server 与一条窄的、显式启用的 Shared TUI compatibility deployment,不是通用本机 Server。 +具体 `EventQueue` 仍由 Core 产品装配;当前 Shared IPC 只把 TUI 必需的强类型操作和事件映射到同一个 Runtime owner, +没有公开协议承诺。是否以 App Server Shared transport 替换并删除它,由行为等价、性能、安全和回滚证据决定。 ## 2. 最少名词 @@ -54,6 +86,8 @@ flowchart TB | Agent Runtime | 负责 Session、Turn、Tool、MCP、Permission、Hook、事件和持久化行为的既有模块 | 进程名、Server 或 SDK | | Embedded deployment | Runtime 与调用入口位于同一 Rust 进程 | 简化版 Runtime | | Shared deployment | 同一 Runtime 由一个本机进程承载,多个第一方 Client 通过私有 IPC 使用 | 新 Runtime、公开 Server 或 Agent SDK | +| Embedded App Server | 与 Rich Client Host 同进程的私有 App Server 实例和 in-memory transport | Runtime 直连、后台进程或网络 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 | @@ -70,13 +104,21 @@ flowchart TB API --> Events["Authoritative events"] end - Embedded["Embedded adapter"] --> API - Shared["Shared local IPC adapter · opt-in TUI"] --> API + Desktop["Desktop GUI"] --> DesktopAdapter["Desktop / Tauri adapter"] + Web["Web UI"] --> AppServer["loopback WebSocket App Server"] + EmbeddedTUI["Embedded TUI"] --> AppServer + DesktopAdapter --> API + AppServer --> API + SharedCompat["Shared Runtime IPC · temporary compatibility"] --> API + Headless["Headless / ACP adapters"] --> API SDK["SDK Host adapter"] --> API Remote["Remote adapter"] --> API ``` -复用的是 Runtime API、权威事实和 owner;不复用 renderer、CLI 参数、SDK wire、远程认证或平台窗口生命周期。任何新能力必须先进入既有 Runtime owner,再由需要它的 adapter 映射,禁止在 Shared 路径复制业务实现。 +当前复用的是 Runtime API、权威事实和 owner;Web 与 Embedded TUI 额外复用 App Server wire,Shared TUI 使用 private v17,Desktop +仍使用自己的 adapter。第 1.2 节目标只有通过评审并完成迁移后才扩大 App Server 复用范围。各入口不复用 renderer、CLI 参数、SDK +wire、远程认证或平台窗口生命周期。任何新能力必须先进入既有 Runtime owner,再由 App Server 或需要它的独立 adapter 映射,禁止 +在 Embedded、Shared 或其他入口复制业务实现。 ### 3.1 Embedded 事件交付 @@ -84,7 +126,9 @@ flowchart TB flowchart LR Queue["EventQueue"] --> Owner["Core product event queue owner"] Owner -->|"injects read-only AgentEventSource"| Runtime["Agent Runtime API"] - Runtime --> TUI["TUI adapter"] + Runtime --> AppServer["Embedded App Server"] + AppServer --> TUI["Interactive TUI client"] + AppServer --> GUI["Desktop GUI client · target"] Runtime --> Exec["Headless adapter"] Runtime --> Peer["Peer fanout adapter"] Runtime --> ACP["ACP adapter"] @@ -92,11 +136,16 @@ flowchart LR ``` - Core product assembly 创建事件 source,并维持旧消费队列的排空 task;第一方产品入口不再获得第二个订阅 API。 -- TUI、Headless CLI 和 Peer Host 只从 `AgentRuntime` 订阅,不能直接持有 Core-specific event source。 +- App Server server 从注入的 `AgentEventSource` 转发 Rich Client 权威事件;Rich Client 不得从 `AgentRuntime` 或 Core `EventQueue` 旁路订阅。 +- 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。 -- 有界 receiver 的 `Lagged` 或 `Closed` 是显式失败;当前没有 cursor/replay 合同,禁止伪装成透明恢复。 -- 这条链路仍全部位于当前 Embedded 进程,不增加 SDK Host、IPC 或后台进程依赖。 +- 当前 App Server 为每条 connection/stream 发送单调 sequence 和 connection-local cursor;`app/syncEvents` 返回当前连接的 cursor + 与 pending Permission snapshot,`session/sync` 恢复 Session state、transcript、workspace binding 和 pending Permission。它没有跨连接 + 持久化 replay/resume:重连后的旧 cursor 不能继续消费,client 必须重新 initialize 并执行权威 sync。 +- Shared Runtime IPC v17 不复用 App Server cursor。它按自己的有界队列规则处理 lag/closed:Agent 流失效后 fail closed;Permission lag + 尝试从 Runtime 的 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。任何路径都不能把流失效伪装成透明恢复。 +- 这条链路仍全部位于当前 Embedded 进程;Rich Client 使用 private in-memory transport,不增加 SDK Host、跨进程 IPC 或后台进程依赖。 ## 4. Process View · Level 1 @@ -130,7 +179,7 @@ flowchart TD | Desktop 打开多个 workspace | 首次 attach/write 时逐个取得并持有文件锁 | 不把窗口数、Session 数等同于 Runtime 进程数 | | 只读 list/view | 不加锁 | ownership 只管理 Runtime deployment,不扩大成读取权限 | | 已解析且带有效 `connection_id` 的 remote workspace | 本机不加锁 | 与 Session storage 的远端判据一致;`host` 提示本身不能绕过本地锁 | -| 当前只读 HTTP Server | 不创建 Core owner | 没有 Agent Runtime 就没有 ownership 可声明 | +| 当前 loopback HTTP Server | 通过 server bootstrap 创建 Embedded Core owner | 只覆盖 Server Host 实际打开的本机 workspace;不因存在 WebSocket route 扩大为远程或多用户 ownership | `CoreRuntimeOwnership` 只选择 deployment、产品 identity 并在进程存活期间持有锁;`services-core` 只负责 canonical key 和跨进程锁。二者都不选择 workspace、不启动 Runtime,也不替代 Session 单写、数据库事务、文件冲突控制或安全沙箱。 @@ -207,7 +256,7 @@ sequenceDiagram end ``` -当前私有协议(v16)只覆盖 TUI 已有用户旅程需要的窄操作: +当前私有协议(v17)只覆盖 TUI 已有用户旅程需要的窄操作: | 已支持 | 明确不支持 | |---|---| @@ -234,6 +283,9 @@ sequenceDiagram - v14 增加三个 current-root-controller 限定的 lineage operation:查询 Runtime 归一化后的扁平 lineage、读取已验证后代的权威 transcript,以及取消指定后代的活动执行子树。查询和读取可在根 Turn 活动时执行;取消复用现有 Session abort 语义,但不切换 controller,也不引入 observer、detach、分页或通用 Session RPC。 - 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 与 `TuiBackend` 的行为等价,没有增加 replay、observer、通用 controller + transfer、多 Session multiplex 或公开 SDK 能力。 - 一个连接最多控制一个 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 所需的最小只读投影。 @@ -261,7 +313,8 @@ flowchart LR | 路径 | 数据边界 | 性能约束 | |---|---|---| -| Embedded | 第一方 adapter 以 Rust 类型直接调用 `AgentRuntime` | 不初始化本机 IPC,不执行 JSON framing、序列化或反序列化 | +| Embedded Rich Client | `AppServerClient` 通过 private in-memory transport 调用同进程 App Server | 不初始化跨进程 IPC 或后台进程;保持与 Shared 相同的 JSON-RPC、DTO、错误和事件语义,编解码成本通过测量优化而不增加直连旁路 | +| 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 上限;超限使事件流明确失效,不能无界分配 | | Shared receive | 每个方向只有一个严格 transport decode 边界 | 未知信封字段和不兼容版本 fail closed;严格校验可以检查规范化 JSON,但不能把动态 JSON 传入 Runtime owner | @@ -275,8 +328,13 @@ flowchart LR ```mermaid flowchart TB - GUI["GUI adapter"] --> API["Agent Runtime API"] - TUI["TUI adapter"] --> API + 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"] + DesktopAdapter --> API["Agent Runtime API / owner ports"] + AppServer --> API["Agent Runtime API / owner ports"] + SharedCompat --> API CLI["Headless CLI adapter"] --> API SDK["SDK Host adapter"] --> API ACP["ACP adapter"] --> API @@ -285,7 +343,9 @@ flowchart TB Coordinator --> Behavior["single behavior owners"] GUI -. "composition" .-> Ownership["CoreRuntimeOwnership"] - TUI -. "Embedded / opt-in Shared" .-> Ownership + Web -. "composition" .-> Ownership + EmbeddedTUI -. "Embedded" .-> Ownership + SharedTUI -. "Shared" .-> Ownership CLI -. "Embedded" .-> Ownership SDK -. "Embedded" .-> Ownership ACP -. "Embedded" .-> Ownership @@ -295,39 +355,47 @@ flowchart TB ```mermaid flowchart LR - CLI["apps/cli"] --> Client["CLI Runtime client"] - Client -->|"Embedded"| Runtime["execution/agent-runtime"] - Client -->|"Shared only"| IPC["adapters/agent-runtime-ipc"] + TUI["Interactive TUI"] --> Backend["TuiBackend"] + Backend -->|"Embedded"| Client["AppServerClient"] + Client --> Memory["in-memory transport"] + Memory --> AppServer["BitfunAppServer"] + Backend -->|"Shared compatibility"| IPC["adapters/agent-runtime-ipc v17"] IPC --> Handler["CLI Shared handler"] + AppServer --> Runtime["execution/agent-runtime / owners"] Handler --> Runtime - Runtime --> Ports["runtime ports / owners"] ``` -CLI adapter 负责命令解析、TUI 状态和错误文案;私有 IPC 只负责本机传输、连接控制和类型映射;Agent Runtime 与 owner 负责 Session 校验、持久化和权威结果。业务代码通过同一个 CLI Runtime client 调用能力,不根据部署形态复制业务分支。 +CLI Host 负责命令解析、TUI 状态、错误文案、App Server 组装和 transport 生命周期;`TuiBackend` 隔离当前 Shared compatibility adapter。 +App Server 或私有 IPC 只负责协议、连接控制和类型映射;Agent Runtime 与 owner 负责 Session 校验、持久化和权威结果。 +TUI 业务代码不根据部署形态复制业务分支,Shared 达到 App Server 语义等价后替换 compatibility adapter。 - CLI 不依赖 SDK Host,GUI/TUI 也不依赖公开 SDK package。 -- 交互式 TUI 的启动页和会话页复用一个 CLI 私有 Runtime client;Session、Turn、Permission 和事件订阅都使用 Rust Runtime SDK(当前 preview)。该 client 只是第一方 adapter,不是公开 SDK、SDK Host client 或第二套 Runtime。 +- 交互式 TUI 的启动页和会话页复用 app-local `TuiBackend`;Embedded backend 使用正式 `AppServerClient`,Shared backend 暂时映射 private Runtime IPC v17。TUI 不直接依赖 Rust Runtime SDK、Core/Service owner 或 IPC operation。 - Headless CLI 和 Peer Host 使用同一 Runtime 订阅入口,但分别保留确定性退出与 Peer fanout 语义;共享订阅入口不等于共享 renderer 或产品生命周期。 -- TUI 不是 Server;未来是否连接 Shared deployment 是部署选择,不改变 TUI 的 renderer/键位职责。 +- TUI 不是 Server;Embedded Host 在同进程组装私有 App Server,是否连接 Shared deployment 是部署选择,不改变 TUI 的 renderer/键位职责或 App Server 行为合同。 - Agent SDK Host 只服务外部 SDK 合同,不成为第一方 rich-client 的通用底座。 - Headless CLI 默认继续 Embedded;CI 或测试可保持独立进程和独立 workspace,不承担后台实例成本。 -- Tauri 仍负责窗口和桌面能力;未来它可以管理 Shared process 的启动/重连,但不拥有 Agent Runtime 业务生命周期。 +- Tauri 仍负责窗口和桌面能力,并逐步收窄为 App Server Host adapter;未来它可以管理 Shared process 的启动/重连,但不拥有 Agent Runtime 业务生命周期。 ### 5.2 Physical View ```mermaid flowchart TB subgraph Embedded["默认 Embedded"] - TUI["TUI / Headless / CI"] --> Direct["in-process Agent Runtime"] + TUI["Interactive TUI"] --> AppServer["private in-process App Server"] + AppServer --> Runtime["in-process Agent Runtime"] + Headless["Headless / CI"] --> Runtime end subgraph Shared["显式 --shared"] - Clients["one or more TUI processes"] -->|"Named Pipe / UDS"| SharedRuntime["Shared Runtime process"] + Clients["one or more TUI processes"] -->|"Named Pipe / UDS · current compatibility"| SharedRuntime["Shared Runtime process"] end - Direct --> Data["workspace + Session storage"] + Runtime --> Data["workspace + Session storage"] SharedRuntime --> Data ``` -默认交互式 TUI、Headless CLI 和 CI 保持 Embedded。只有显式 `--shared` 的交互式 TUI 进入 Shared;同一 workspace 的两种部署互斥。多开 TUI 增加 Client 进程和有界连接,不按 Client 数量复制 Runtime、Session owner 或 Plugin Host。 +默认交互式 TUI、Headless CLI 和 CI 保持 Embedded;交互式 TUI 通过 private in-process App Server,Headless/CI 保留独立 adapter。 +只有显式 `--shared` 的交互式 TUI 进入 Shared;同一 workspace 的两种部署互斥。多开 TUI 增加 Client 进程和有界连接, +不按 Client 数量复制 Runtime、Session owner 或 Plugin Host。 ### 5.3 Scenario (+1) · Rename current Session @@ -335,16 +403,28 @@ flowchart TB sequenceDiagram participant U as User participant T as TUI adapter - participant C as CLI Runtime client + participant B as TuiBackend + participant E as Embedded App Server adapter + participant S as Shared Runtime IPC v17 adapter participant R as Agent Runtime U->>T: /rename Auth refactor T->>T: trim + require idle Session - T->>C: rename_session(id, name) - C->>R: direct call or one Shared frame - R->>R: validate ownership + persist - R-->>C: applied / failed / outcome_unknown - C-->>T: typed result + T->>B: typed TuiBackend request + alt Embedded + B->>E: typed App Server request + E->>R: owner port call + R->>R: validate ownership + persist + R-->>E: applied / failed / outcome_unknown + E-->>B: mapped typed result + else Shared compatibility + B->>S: Runtime IPC v17 request + S->>R: owner port call + R->>R: validate ownership + persist + R-->>S: applied / failed / outcome_unknown + S-->>B: mapped typed result + end + B-->>T: typed result T-->>U: update name only after applied ``` @@ -356,16 +436,28 @@ Embedded 和 Shared 最终调用同一 `AgentRuntime::rename_session`。Runtime sequenceDiagram participant U as User participant T as TUI adapter - participant C as CLI Runtime client + participant B as TuiBackend + participant E as Embedded App Server adapter + participant S as Shared Runtime IPC v17 adapter participant R as Agent Runtime U->>T: /sessions then Ctrl+D T->>T: reject current or active target - T->>C: delete_session(id) - C->>R: direct call or one Shared frame - R->>R: existing delete owner - R-->>C: applied / failed / outcome_unknown - C-->>T: typed result + T->>B: typed TuiBackend request + alt Embedded + B->>E: typed App Server request + E->>R: owner port call + R->>R: existing delete owner + R-->>E: applied / failed / outcome_unknown + E-->>B: mapped typed result + else Shared compatibility + B->>S: Runtime IPC v17 request + S->>R: owner port call + R->>R: existing delete owner + R-->>S: applied / failed / outcome_unknown + S-->>B: mapped typed result + end + B-->>T: typed result T-->>U: remove only after applied ``` @@ -413,17 +505,20 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 | 产品 | 已验证做法 | BitFun 采用 | 不照搬 | |---|---|---|---| -| [OpenCode Server/SDK](https://opencode.ai/docs/server/) | Server-first;类型化 SDK 直接消费 Server API | 一个 Runtime owner 可以服务多个第一方 Client | 不让默认 TUI 承担 HTTP/OpenAPI 编解码,也不把全量 route 固化为私有 Shared wire | -| [Codex App Server](https://developers.openai.com/codex/app-server/) | App Server 为 rich client 和 remote TUI 提供 JSON-RPC;自动化继续使用 SDK;WebSocket transport 仍是实验性接口 | rich-client 私有协议与公开 SDK 分层,并为 Shared 入口保留有界本机 transport | 不让默认 CLI 依赖 App Server,也不复制其完整 schema 或实验性远程 transport | -| [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/typescript) | Agent loop 由长期运行的 CLI 子进程承载,并提供 `startup()` 预热以减少首次请求成本 | 长期交互可以复用已启动进程,空闲后回收 | 不让第一方 Embedded TUI 为接口统一付出子进程和编解码成本,也不把多 TUI 映射为多个 Runtime | +| [OpenCode Server/SDK](https://opencode.ai/docs/server/) | Server-first;类型化 SDK 直接消费 Server API | 一个 Runtime owner 可以服务多个第一方 Client | 不要求 Rich Client 使用 HTTP/OpenAPI,也不把全量 route 固化为私有 Shared wire | +| [Codex App Server](https://developers.openai.com/codex/app-server/) | App Server 为 rich client 和 remote TUI 提供 JSON-RPC;自动化继续使用 SDK;WebSocket transport 仍是实验性接口 | Rich Client 使用 App Server,自动化/公开 SDK 保持独立,并为 Shared 入口保留有界本机 transport | 不复制其完整 schema,也不把实验性远程 transport 当作已交付公网 API | +| [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/typescript) | Agent loop 由长期运行的 CLI 子进程承载,并提供 `startup()` 预热以减少首次请求成本 | 长期 Shared 交互可以复用已启动进程,空闲后回收 | Embedded Rich Client 不增加子进程,多 TUI 也不映射为多个 Runtime | -三种产品说明了不同部署的有效边界:server-first 适合稳定多客户端协议,长期子进程适合语言 SDK,进程内调用适合默认本机交互。BitFun 采用混合部署,不把任何一种形态强制成所有入口的公共底座;当前也没有为了追赶功能表一次性增加 Session/Tool/Permission 超集。 +三种产品说明了不同部署的有效边界:稳定 Rich Client 合同可以同时承载进程内和多客户端 transport,长期子进程适合 Shared +交互或语言 SDK,独立强类型 adapter 适合 Headless/ACP 等非 Rich Client。BitFun 采用混合部署,不把 App Server 强制成所有入口的 +公共底座;当前也没有为了追赶功能表一次性增加 Session/Tool/Permission 超集。 ## 9. 不变量 - 只有一套 Agent Runtime 业务实现;部署差异不能产生第二套 Session、Tool、Permission 或 MCP owner。 +- 当前入口使用第 1.1 节列出的 adapter;若第 1.2 节目标通过评审并迁移完成,Desktop GUI、Web UI 和交互式 TUI 才统一使用 App Server。 - Client、窗口、Session 或 workspace 数量不会自动等量增加 Runtime 或 Plugin Host 进程。 -- 私有 IPC 不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议。 +- 当前 Shared Runtime IPC 是第一方 TUI 的 private compatibility transport,不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议;是否由 App Server Shared transport 替换仍待评审。 - 默认 GUI/TUI/Headless CLI、ACP 与 SDK Host 保持 Embedded;只有交互式 TUI 的显式 `--shared` 选择 Shared。互斥按 `workspace + product` 生效,不再按入口名称缩窄。 - Account/session cloud sync 仍使用既有 Core compatibility 边界,不属于 Shared Runtime 支持。 - Remote workspace 的文件、凭据、进程和 Runtime 位于目标执行域,禁止静默回落本机。 diff --git a/docs/architecture/app-server-architecture.md b/docs/architecture/app-server-architecture.md new file mode 100644 index 0000000000..cbe41a594d --- /dev/null +++ b/docs/architecture/app-server-architecture.md @@ -0,0 +1,394 @@ +# App Server 架构设计 + +> 状态:Proposed target;关键决策与替换门槛尚待架构评审。 +> +> 基线日期:2026-08-05。 +> +> 本文记录 BitFun Rich Client 与产品后端之间的候选 App Server 边界,不是已批准的权威架构。具体 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)。评审完成前,当前调用路径以已接线代码和上述稳定架构文档为准。 + +## 1. Proposed decision + +当前首选候选是用 App Server 统一第一方 Rich Client 的产品后端接口。它尚未批准;下列约束只描述该候选被选中后的目标状态: + +- Desktop GUI、Web UI 和交互式 TUI 都是 App Server Rich Client。 +- Rich Client 的 Embedded deployment 也必须经过 App Server;它创建同进程私有 App Server,并通过私有 in-memory transport 连接。 +- Embedded 不表示直连 Runtime,也不要求独立后台进程、网络监听或跨客户端实例发现。 +- Embedded 与 Shared 复用同一 App Server client、协议版本、method、DTO、类型化错误、能力发现、事件和取消语义。 +- Embedded 与 Shared 只在 transport、App Server 实例所有权、客户端数量、连接治理和资源生命周期上不同。 +- Headless CLI/CI、ACP、Peer Host 和公开 Agent SDK 不是 Rich Client,不因该候选被强制改用 App Server;它们继续使用各自经评审的 adapter。 +- App Server 是协议适配层,不接管 Agent Runtime、Service 或 Product Domain 的业务所有权。 + +若选择该候选,不能用“Embedded 位于同一进程”作为 Rich Client 绕过 App Server 的理由,也不能用“统一 GUI/TUI 接口”把所有自动化和外部协议强制收敛到 App Server。 + +### 1.1 Alternatives under review + +| 候选 | 结构 | 收益 | 成本与风险 | 采用门槛 | +| --- | --- | --- | --- | --- | +| A. App Server-first Rich Clients(当前首选) | Desktop、Web、Embedded/Shared TUI 复用一个 wire 与 typed client | 跨 Rich Client 合同和 fixture 最集中 | Embedded 编解码与 runtime/thread 成本;Desktop/Web 迁移面大;Shared 必须重新交付连接治理 | 真实 Desktop/TUI consumer、跨 transport parity、性能和安全门槛全部通过 | +| B. Deployment-specific product adapters | Desktop、Web、Embedded TUI、Shared TUI 各保留窄 adapter,共享 owner ports | 每个 Host 可按自身生命周期优化,迁移风险较低 | DTO、错误、恢复和行为 fixture 可能分叉;跨入口一致性需额外治理 | 证明长期重复成本低于统一 wire 成本,并建立跨 adapter 行为合同 | +| 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 不能假装同一协议 | 证明共享 use case 不泄漏 Runtime 实现,并分别验证每条 wire 的故障语义 | + +评审可以选择 A、B、C 或其受限组合。已有 `TuiBackend`、App Server 和 v17 是评估证据,不自动决定最终架构。 + +### 1.2 Costs of the preferred candidate + +- Embedded Rich Client 需要承担 App Server client/server、JSON-RPC 编解码、事件队列和专用 runtime/thread 的启动、内存与延迟成本;必须以基准证明该成本可接受。 +- 迁移期会同时维护 App Server 与 Runtime IPC v17 两条 wire;新增核心用例需保持 `TuiBackend` 行为等价,不能让双写期形成两个业务 owner。 +- Shared App Server 需要重新交付 v17 已有的 framing、方向性 limits、鉴权、实例身份、controller/lease、断连取消、未知结果和空闲退出,不能只复用 method/DTO。 +- Desktop 迁移必须划清 controller-local capability、Tauri 生命周期和工作区 Host capability;Web/Remote 扩展还需要独立的认证、授权和多租户资源治理。 + +### 1.3 当前实现状态 + +目标架构与已交付能力必须分开描述: + +| 范围 | 当前状态 | 目标 | +| --- | --- | --- | +| Embedded TUI | 已创建私有 `BitfunAppServer`,通过 in-memory transport 连接 `AppServerClient` | 完成剩余管理面迁移和行为等价验证 | +| Shared TUI | 仍通过私有 Runtime IPC v17 连接独立 Runtime Host | App Server Shared transport 达到可靠性等价后迁移 | +| Desktop GUI | 主要仍使用 Tauri command 和桌面事件投影 | Tauri 收窄为 Host adapter,产品请求统一进入 App Server | +| 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。只有候选 A 获批且替换门槛通过后,才迁移或删除该 IPC;候选 B/C 可能将 private v17 或后继协议保留为受控的长期物理 wire。 + +### 1.4 Decision and replacement gates + +在满足下列门槛前,不得把候选 A 标记为 approved,也不得用 Shared App Server 替换 v17: + +| 门槛 | 必需证据 | +| --- | --- | +| Framing 与 limits | request/response/event/attachment 的方向性上限、无界分配防护、慢 client/backpressure 和超限结果均有跨 transport 测试 | +| 身份与作用域 | 实例身份、每连接认证、user/product/workspace/execution-domain 绑定和 method allowlist fail closed | +| Controller 与 Session 单写 | controller/observer/lease、断连隔离、跨进程 Session writer 冲突和转移规则有 owner-level 决策与竞争测试 | +| 事件恢复 | 明确 snapshot/replay owner、连接内 cursor、跨连接是否持久化、lag/closed/invalidation 和 resync 行为 | +| 取消与未知结果 | disconnect/shutdown 取消、迟到响应、operation identity、`outcome_unknown` 查询/恢复和禁止盲重试 | +| Host capability | Desktop local effect 与工作区 capability 边界、provider 注入、Remote unsupported 和 Web/Remote auth 已定稿 | +| 生命周期与性能 | discovery、startup、idle exit、crash cleanup、延迟、吞吐、内存和 Embedded thread/runtime 成本有预算与测量 | +| 迁移与回滚 | 同一第一方 consumer 完成 opt-in 双栈 parity;升级/降级和 v17 rollback 可重复验证;删除条件有明确 owner 批准 | + +## 2. 问题与目标 + +GUI、Web 和 TUI 若分别围绕 Tauri command、WebSocket route、CLI/Core 直连维护产品接口,会产生以下问题: + +- 同一用例出现多套 DTO、错误码、默认值和字段归一化。 +- 某一入口完成权限、取消或远程工作区支持,其他入口仍静默缺失。 +- 事件被不同 Host 投影后丢失身份、顺序或恢复信息。 +- UI 组件与 Tauri、Core singleton 或私有 Runtime IPC 绑定,无法验证跨入口行为等价。 +- “handler 已存在”“DTO 已生成”或“能力被硬编码为 available”被误当成端到端能力已交付。 + +App Server 的目标是提供一个可版本化、可生成 client、可跨 Embedded/Shared transport 验证的 Rich Client 合同,同时保持业务 owner 平台无关。它统一的是产品后端行为,不统一 GUI/TUI renderer、布局、键位、窗口、终端或 controller-local effect。 + +## 3. 范围与非目标 + +本文范围包括: + +- Rich Client 的请求、响应、notification、错误、取消和恢复合同。 +- Embedded、Shared 和 WebSocket Host 的 transport 与生命周期边界。 +- Host 能力、transport limit、身份和执行域的协商。 +- Desktop/Tauri、Web 和 TUI 的接入规则。 +- App Server crate、Runtime owner 和产品装配之间的依赖方向。 + +本文不负责: + +- 迁移 Runtime owner、重写 Session/Turn/Permission/MCP 等业务实现。 +- 把 App Server 变成通用 Core RPC、Tool RPC 或任意内部函数调用协议。 +- 强制 Headless CLI/CI、ACP、Peer Host 或公开 Agent SDK 使用 App Server。 +- 统一 GUI 与 TUI 的状态机、renderer、布局、主题键或键位模型。 +- 把 WebSocket transport 宣称为已具备多用户或公网安全性的公开 API。 +- 为旧 Tauri command、旧 Web route 或 Runtime IPC 永久建立平行兼容合同。 + +## 4. 术语 + +| 名词 | 含义 | 不等于 | +| --- | --- | --- | +| App Server | 将版本化 Rich Client wire 映射到 Runtime API、Service 和 Product Domain owner 的协议适配层 | 业务 owner、通用 RPC 总线、必然独立的进程 | +| App Server Client | 只依赖 wire contract、由 Host 提供 transport 的类型化客户端 | Runtime SDK、Server 构造器、UI 状态 owner | +| Rich Client | 需要持续会话、交互事件和产品管理面的第一方 GUI/Web/TUI | Headless automation、ACP、公开 SDK | +| Host | 组装 App Server、选择 transport、注入能力并管理生命周期的产品入口 | 新业务层、普通用户必须管理的 Server 产品 | +| Embedded App Server | 与 Rich Client Host 位于同一 OS 进程的私有 App Server 实例 | Runtime 直连、网络 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 中存在的方法全集 | +| controller-local effect | 剪贴板、外部编辑器、终端 raw mode、窗口和本地导出等只属于控制端的行为 | 工作区或 Runtime 能力 | + +## 5. 逻辑架构 + +```mermaid +flowchart LR + subgraph Clients["Rich Clients"] + GUI["Desktop GUI"] + Web["Web UI"] + TUI["Interactive TUI"] + end + + GUI --> Host["Host adapter"] + Web --> Host + TUI --> Host + Host --> Client["App Server Client"] + Client --> Transport["Host-selected transport"] + Transport --> Server["App Server"] + Server --> API["Runtime API / owner ports"] + API --> Owners["Runtime · Services · Product Domains"] +``` + +依赖和调用方向始终从入口流向 owner。Host 负责 transport 认证、连接作用域、capability/allowlist 和平台能力;App Server handler +负责 method 合同校验、handler 注册、DTO 转换和 Runtime/domain error 到 wire error 的映射。业务一致性、权限上限、持久化和权威状态 +仍由对应 owner 提交。 + +### 5.1 四层合同 + +| 层 | 负责 | 不负责 | +| --- | --- | --- | +| 行为合同 | 用例语义、状态转移、权限、幂等/重试、错误、事件和恢复条件 | transport framing、UI 展示 | +| Wire 合同 | method、DTO、版本兼容、类型化错误和 notification envelope | Runtime 内部类型、Host 句柄 | +| Host 合同 | transport、可用能力、限制、身份、作用域、生命周期和 controller-local provider | 复制业务规则或权威状态 | +| Owner 合同 | Runtime/Service/Product Domain 的业务事实、校验和提交 | JSON-RPC、Tauri、WebSocket、Ratatui | + +行为合同是 Embedded 与 Shared 等价的核心。仅复用 JSON 字段但在断连、超时、事件落后或权限上表现不同,不算统一 App Server 接口。 + +## 6. Embedded deployment + +Embedded Rich Client 的标准路径是: + +```text +Rich Client + -> Host adapter + -> AppServerClient + -> private in-memory transport + -> private App Server instance + -> Runtime API / owners +``` + +Embedded Host 必须: + +1. 组装 Runtime 和 App Server,并将同一 owner 的端口注入 server。 +2. 创建方向固定的私有 in-memory transport pair。 +3. 通过正式 App Server Client 完成 initialize、请求、事件和 shutdown。 +4. 保证 server task/thread 在 Host 退出时被取消并回收。 +5. 使用与 Shared 相同的 schema、错误和行为测试。 + +Embedded 可以省略只对跨进程多客户端有意义的机制:endpoint discovery、进程 token、外部实例锁、多客户端 controller lease 和空闲后台退出。省略这些机制不能改变请求结果、事件顺序、取消结果或 capability 语义。 + +进程内 transport 仍可能执行 JSON-RPC 编解码。该成本是候选 A 必须测量的工程取舍;只有基准、资源预算和真实 consumer 证明可接受后, +才能把强制经过 App Server 作为批准约束。允许评估 transport buffer、生成代码和批量事件优化,但不能用未经验证的性能假设提前排除候选 B/C。 + +## 7. Shared deployment + +Shared deployment 由一个本机 App Server Host 承载一个 Runtime owner,多个第一方 Rich Client 通过受控 Pipe、UDS 或等价私有 transport 连接: + +```mermaid +flowchart LR + C1["GUI/TUI client 1"] --> IPC["Private local transport"] + C2["GUI/TUI client 2"] --> IPC + CN["GUI/TUI client N"] --> IPC + IPC --> AS["Shared App Server Host"] + AS --> R["One Agent Runtime"] + R --> D["Workspace and Session storage"] +``` + +Shared Host 在基础 App Server 合同之外必须提供: + +- 安全 endpoint discovery、实例身份和同用户认证材料。 +- initialize-first 握手、协议版本和 client identity 校验。 +- workspace、用户、产品和 execution domain 绑定。 +- 连接数、请求队列、事件队列和 frame 大小上限。 +- 每个 Session 的 controller/lease、冲突和转移规则。 +- 断连时取消连接拥有的活动操作,并隔离未完成清理的 lease。 +- 有序 writer、并发 reader、背压和慢 client 失效策略。 +- 无客户端且无活动任务时的受控空闲退出。 +- 副作用请求在超时或断连后的 `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 再以功能回退换取表面统一。 + +## 8. Desktop GUI 与 Tauri + +Desktop 的目标调用路径是: + +```text +React UI + -> frontend infrastructure / generated App Server client + -> Desktop Host transport adapter + -> Embedded or Shared App Server +``` + +Tauri 继续拥有窗口、菜单、系统托盘、文件选择器、剪贴板、通知和进程级生命周期。Session、Turn、Workspace、Permission、Config、MCP、Skill、Hook 等产品后端能力必须迁入 App Server。 + +迁移规则: + +- UI 组件不得直接调用 Tauri API;调用进入前端 infrastructure/adapter。 +- Tauri command 若只承载产品后端用例,应由 App Server method 替代并逐步删除。 +- 必须由桌面原生 API 完成的 client-local capability 保留 Host-native 实现;需要与工作区或 Runtime 交互时拆成 App Server 数据流和本地 effect 两段。 +- Tauri event bridge 只能投递 App Server typed notification 或桌面专属事件,不能形成第二套 Runtime 事件语义。 +- Desktop Host 可在 Embedded 与 Shared 之间切换,但 UI 和生成 client 不包含 Runtime 直连分支。 + +## 9. Web 与远程 Host + +WebSocket 是 App Server 的一种 transport,不是另一套业务 API。Web Host 必须使用同一 method、DTO、错误和事件合同,同时根据部署场景构造显式 capability allowlist。 + +当前 WebSocket Host 只适用于单用户、loopback、受控 Origin 场景。Origin allowlist 和 loopback bind 不能替代以下安全机制: + +- 每连接认证和不可伪造的 client identity。 +- 用户、workspace、产品和 execution domain 的作用域绑定。 +- method/capability allowlist 与 owner 级授权。 +- permission context、审计身份和撤销。 +- 连接、请求、frame、事件速率和资源配额。 + +在这些机制交付并验证前,不得把当前 WebSocket Host 暴露到不可信网络、多用户部署或公开 SDK。Remote workspace 的 Runtime、凭据、文件和进程必须位于目标执行域;Host 不得在远端能力缺失时静默回退 controller 本机。 + +## 10. 能力发现与 transport limits + +`app/initialize` 返回的是当前连接实际可用的能力和 transport 限制,不是 protocol crate 中所有 DTO 的静态清单。 + +能力状态由 Host 根据以下事实构造: + +- 产品组装结果和 delivery profile。 +- 当前注入的 Runtime/Service/Product Domain provider。 +- transport、平台和远程执行域的支持程度。 +- 用户、组织和连接级策略。 +- provider 健康与当前降级状态。 + +规则如下: + +1. 只有生产 handler、provider、授权和行为测试都存在时,能力才可标记为 `Available`。 +2. 不可用能力保留稳定 ID,并返回类型化 `Unavailable { reason }` 或 `unsupported`,不能静默回退旧路径。 +3. Host 未注入 provider 时不得因 handler 或 DTO 存在而宣传能力,例如 context reload。 +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 的有效上限。 + +## 11. 事件、恢复与取消 + +权威 Runtime 事件通过同一 App Server connection 以 typed notification 发送。Host 不得让 client 绕过 App Server 直接订阅 Core `EventQueue`,也不得用有损 frontend projection 替代权威事件流。 + +每个事件流至少需要: + +- 稳定 stream identity。 +- 单调 sequence/cursor。 +- 与 Session、Turn、request 和 execution domain 的关联身份。 +- `closed`、`lagged`、`invalidated` 和 `recoverable` 的明确区分。 +- snapshot/sync 或要求重新装载 Session 的恢复指令。 + +client 落后、frame 超限或连接中断时不能假装事件完整。可恢复流从 server 确认的 cursor/snapshot 继续;不可恢复流进入 invalidated,UI 必须停止基于旧 read model 提交依赖状态的新操作,直到 resync 完成。 + +取消属于行为合同: + +- 每个活动请求和长任务具有稳定 request/operation identity。 +- client 取消、Host shutdown 和连接断开映射到对应 owner 的取消路径。 +- Shared Host 只取消该连接拥有的操作,不影响其他 controller 的独立任务。 +- 取消完成与“取消请求已接收”必须区分;资源和 lease 仅在终态确认后释放。 + +## 12. 副作用、超时与重试 + +有副作用的请求必须携带可关联的 request identity,并由合同声明重试语义。若 client 在请求可能已提交后超时或断连,结果必须返回或投影为 `outcome_unknown`: + +- `outcome_unknown` 默认 `retryable = false`。 +- client 禁止盲目重试 create、submit、permission response、rename、delete 等 mutation。 +- client 应先通过 request identity、Session snapshot 或 owner 查询确认结果,再决定恢复动作。 +- 纯查询只有在合同声明幂等且不会扩大资源消耗时才可自动重试。 + +Embedded 虽然较少发生物理断连,也必须保留同一错误类型和 client 处理分支,确保切换 Shared 后不会改变产品行为。 + +## 13. 安全模型 + +App Server 是完整产品控制面,安全决策必须绑定到连接和业务作用域,而不是只信任 transport 地址。 + +| 维度 | 要求 | +| --- | --- | +| 连接身份 | initialize 前完成 transport 级认证;建立不可伪造的 connection/client identity | +| 实例身份 | Shared client 校验 discovery 得到的实例与握手返回一致,拒绝陈旧或替换实例 | +| 业务作用域 | 每个连接绑定 user、product、workspace 和 execution domain;请求不能通过 path 字符串越界 | +| 权限上下文 | permission request/response 关联 client、Session、Turn 和审计主体,Host 不能提高 owner 策略上限 | +| 能力暴露 | Host 使用显式 allowlist;未知方法和未装配能力 fail closed | +| 资源治理 | 连接、请求、frame、队列、并发、速率和任务生命周期有界 | +| 远程边界 | 凭据、文件、进程和 Runtime 留在目标执行域;禁止本地 fallback | + +Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传递明确的 Host/connection context,不能让 handler 从全局环境猜测调用主体。 + +## 14. Crate 与所有权边界 + +| 路径 | 所有权 | +| --- | --- | +| `src/crates/interfaces/app-server-protocol` | behavior-light wire DTO、method、错误、事件 envelope 和角色定义 | +| `src/crates/interfaces/app-server-client` | transport-agnostic client、请求和 notification 分发 | +| `src/crates/interfaces/app-server` | server 生命周期、生产 handler 注册、wire/owner 转换和 Runtime 错误映射 | +| `src/apps/*` | Host 组装、transport、身份、capability/limit 构造、生命周期和平台能力 | +| `contracts/*`、`execution/*`、`services/*`、`assembly/core` | 稳定事实、Runtime 行为、具体服务和产品 owner | + +边界规则: + +- protocol/client 的依赖闭包不得引入 `bitfun-core`、Runtime 实现、Service 实现或 `product-full`。 +- server wiring 可以依赖生产 handler 所需的明确 owner feature,但禁止选择 `bitfun-core/product-full`。 +- 新 domain 只能增加真实 handler 所需的最窄 owner feature,并通过边界检查证明依赖方向。 +- protocol DTO 不复制 Runtime 内部对象;只暴露 Rich Client 需要的稳定字段和 read model。 +- transport 实现留在 Host/adapter 边界,generic role/transport helper 保持 schema-free。 +- App Server 不持有第二份 Session、Permission、Config 或 capability 权威状态。 + +## 15. 迁移顺序 + +迁移按行为闭环推进,不按 method 数量推进: + +1. **锁定合同基础**:稳定 protocol/client crate、版本、错误、能力、限制和事件 envelope;增加 Embedded contract test。 +2. **完成 Embedded TUI**:所有交互式 TUI 产品请求经 `TuiBackend -> AppServerClient`;移除 Core、Runtime SDK、Service singleton 和 Runtime IPC 的 TUI-facing 依赖。 +3. **迁移 Desktop GUI**:按 Session/Turn/Permission、Workspace、Config/MCP/Extension 等垂直切片迁移;每片完成后删除重复 Tauri DTO/handler。 +4. **补齐 Shared 语义**:把 authentication、instance identity、controller/lease、framing、背压、断连取消、idle exit、event recovery 和 `outcome_unknown` 纳入 App Server Host/transport。 +5. **评审 Shared TUI 迁移**:候选 A 获批且 1.4 节门槛通过后,才用同一 client/schema 替换 Runtime IPC compatibility adapter;旧 wire 仅在 rollback 窗口结束并获得 owner 批准后删除。若选择 B/C,则记录 v17 的长期 owner、版本和删除条件。 +6. **收紧 Web Host**:由 Host 注入 allowlist、作用域和真实 limits;完成安全绑定前保持 loopback 单用户限制。 +7. **删除旁路**:移除 Rich Client 的 Core/Runtime 直连、重复事件投影和无生产消费方的旧 route。 + +迁移期间不得在 App Server 返回 unsupported 后静默调用旧 Tauri/Core/IPC 路径。需要暂存旧路径时,必须由 Host 在启动时明确选择完整 adapter,且 UI 只看到一个 `TuiBackend` 或 frontend infrastructure 接口。 + +## 16. 验证与完成标准 + +### 16.1 必需验证 + +- protocol serialization、版本上下界、未知字段和类型化错误合同测试。 +- 同一用例在 Embedded in-memory 与 Shared process transport 上的行为等价测试。 +- Host capability/provider/allowlist 组合测试,以及真实 transport limit 测试。 +- request identity、取消、断连、超时和 `outcome_unknown` 测试。 +- 事件顺序、lag、invalidated、cursor/snapshot resync 和慢 client 测试。 +- Shared authentication、instance identity、workspace/execution binding、controller/lease 和 idle exit 测试。 +- Desktop GUI 与 TUI 对 Session、Turn、Permission、Workspace 和配置能力的跨入口等价测试。 +- Cargo 依赖闭包和 `product-full` 禁止规则。 +- TypeScript/Rust client 生成结果与 schema 一致性检查。 + +### 16.2 完成定义 + +若选择候选 A,只有同时满足以下条件,App Server Rich Client 架构才算完成: + +1. Desktop GUI、Web UI 和交互式 TUI 的产品后端请求与订阅均经过 App Server。 +2. Embedded 与 Shared 使用同一 client、method、DTO、错误和事件恢复合同,UI 不包含部署分支。 +3. Shared transport 达到现有 Runtime IPC 的鉴权、lease、取消、背压、限制、失效和生命周期等价。 +4. capability 和 limits 来自 Host 的真实装配与 transport,不再由通用 handler 无条件硬编码。 +5. Rich Client 不直接依赖 Core singleton、Runtime SDK、Tauri 业务 command 或私有 Runtime IPC。 +6. App Server handler 不持有业务权威状态,不复制 owner 校验和策略。 +7. Remote workspace 和多用户连接具有明确身份、作用域、授权和 fail-closed 行为。 +8. 重复 Tauri/Web/IPC DTO、旧 handler 和事件旁路已删除,或有明确的兼容期限与删除证据。 +9. 上述合同、行为、安全、依赖和跨入口测试全部通过。 + +## 17. Proposed constraints and open decisions + +### 17.1 Proposed target constraints + +- 若候选 A 获批,Rich Client Embedded 必须经过私有 in-process App Server。 +- Embedded 和 Shared 只有部署与连接治理差异,不产生第二套产品行为。 +- App Server 只映射 owner,不成为 Session、Turn、Permission、Tool/MCP、Config 或事件 owner。 +- Host capability 必须由真实装配、授权和 transport 共同决定。 +- 事件丢失、断连和未知副作用结果必须显式可见,不能用轮询或盲重试掩盖。 +- Headless CLI/CI、ACP、Peer Host 和公开 SDK 保持独立 adapter,除非另有经评审的真实消费需求。 +- 一个 client、窗口、workspace 或 Session 不默认对应一个 Runtime 或 Plugin Host 进程。 + +### 17.2 尚待实现评审决定 + +- App Server limits schema 是否拆分 request、response、event 和附件/流式传输上限。 +- Shared transport 最终复用现有 Pipe/UDS framing,还是在相同可靠性合同上采用新的 framing adapter。 +- controller/observer/read-only client 的公开 capability 表达和转移 UX。 +- 事件 snapshot 的 owner、粒度、保留窗口和 cursor 持久化策略。 +- Desktop client-local capability 的请求方向:App Server 反向 request、Host provider port,或显式两段式工作流。 +- Web/Remote 的认证凭据来源、刷新、撤销和多租户资源配额。 + +这些待决项会影响候选选择,不能被实现默认值或迁移进度替代。评审结论必须记录所选候选、拒绝其他候选的理由、门槛 owner、验证证据和回滚/删除条件;在此之前,当前 Embedded App Server 与 Shared v17 路径都保持有效。 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 82d3690fcb..62a581e3fd 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -19,7 +19,10 @@ Headless CLI 与各产品入口的统一心智见 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md);多个 GUI/TUI/Remote/CLI/SDK 实例共存时的 Agent Runtime 部署、 状态共享、隔离、容量与 Plugin Host 关系见 -[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md)。详细设计与本文件冲突时,以本文件为准。 +[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md);Desktop GUI、Web UI 和交互式 TUI 的统一产品后端协议、 +Embedded/Shared App Server 边界及迁移约束见 +[`app-server-architecture.md`](app-server-architecture.md)。该专题当前是待评审的目标提案;在决策门槛通过前,当前调用路径和稳定 +owner 边界仍以本文及已接线代码为准。其他已批准的详细设计与本文件冲突时,以本文件为准。 Cargo feature、第三方依赖 owner、测试目标和本地/CI 验证分工见 [`rust-build-dependency-boundaries.md`](rust-build-dependency-boundaries.md)。该文档补充本架构的构建视图,不改变本文定义的运行时 owner 和分层依赖方向。 @@ -304,7 +307,10 @@ flowchart LR ### 2.4 Physical View · Level 0 -Physical View 展示当前可执行单元到设备、主机和存储的映射。Desktop、CLI、ACP 和 SDK Host 使用 Embedded Runtime;交互式 TUI 可以显式连接 Shared Runtime。当前 Web Server 和 Relay Server 都不承载 Agent Runtime。 +Physical View 展示当前可执行单元到设备、主机和存储的映射。Desktop、CLI、ACP 和 SDK Host 使用 Embedded Runtime; +Embedded 交互式 TUI 已在同一 CLI 进程内通过私有 App Server 使用 Runtime,交互式 TUI 也可以显式连接当前 Shared Runtime IPC。 +Desktop GUI 的 App Server 迁移尚未完成。当前 loopback Web Server 已承载 Embedded Runtime 和 WebSocket App Server;Relay Server +不承载 Agent Runtime。 ```mermaid flowchart LR @@ -342,6 +348,9 @@ flowchart LR DesktopApp <-->|WebSocket| RelayServer CLIApp <-->|WebSocket| RelayServer CLIApp -.->|Local IPC| SharedRuntime + WebServer --> WorkspaceData + WebServer -->|spawn| ToolProcesses + WebServer -->|HTTPS| AIProviders RelayServer --> RelayDB RelayServer --> AssetStore EmbeddedNodes --> WorkspaceData @@ -364,12 +373,12 @@ flowchart LR | Deployment unit | Main contents | |---|---| -| Desktop App | Web UI、Tauri Host、embedded Agent Runtime | -| CLI App | TUI、Headless、Peer;默认 Embedded,可显式使用 Shared TUI | -| Shared Runtime | 私有本机 IPC;当前只有交互式 TUI consumer | +| Desktop App | Web UI、Tauri Host、embedded Agent Runtime;Rich Client App Server 迁移尚未完成 | +| CLI App | 交互式 TUI 通过 private in-process App Server 使用 Embedded Runtime;Headless、Peer 保留独立 adapter;可显式使用 Shared TUI | +| Shared Runtime | 私有本机 IPC;当前只有交互式 TUI consumer;是否迁入 Shared App Server transport 仍待评审与等价证据 | | ACP | Embedded Agent Runtime、ACP 协议生命周期 | | SDK Host | 私有跨进程 adapter;公开 SDK 产品尚未交付 | -| Web Server | Health、Info、WebSocket 外壳;不包含 Agent Runtime | +| Web Server | Embedded Agent Runtime、WebSocket App Server、Health/Info;当前只允许 loopback 单用户模式 | | Relay Server | WebSocket/HTTP bridge、账户与同步;不包含 Agent Runtime | ### 2.5 Scenarios (+1) · Level 0 @@ -412,11 +421,15 @@ flowchart TB ## 3. 接口边界 -BitFun 只保留四个稳定接口边界;工具、事件和权限作为归属子接口被复用,不在插件层重复定义。本文使用“接口”描述可被调用或依赖的能力面;只有描述跨进程消息封装、结构化 schema、序列化对象或强兼容约束时才使用“契约”;只读状态视图表示从权威状态派生出的查询结果。 +BitFun 只保留四个稳定业务接口边界;工具、事件和权限作为归属子接口被复用,不在插件层重复定义。App Server +是 Agent Runtime API 和其他 owner 接口面向当前 Web/Embedded TUI 以及候选 Rich Client 目标的版本化 wire adapter,不新增第五个 +业务 owner 或能力分类。是否扩大到全部 Rich Client 由 4.2 节所述评审决定。本文使用 +“接口”描述可被调用或依赖的能力面;只有描述跨进程消息封装、结构化 schema、序列化对象或强兼容约束时才使用 +“契约”;只读状态视图表示从权威状态派生出的查询结果。 | 接口边界 | 谁使用 | 提供 | 不包含 | |---|---|---|---| -| Agent Runtime API | GUI、TUI/CLI、Web、ACP、Server、Remote、SDK adapter | Query、Session、Tool/MCP、Permission、Hook、Event、Usage | UI、协议和具体服务实现 | +| Agent Runtime API | App Server、Headless CLI、ACP、Server、Remote、SDK 等 adapter | Query、Session、Tool/MCP、Permission、Hook、Event、Usage | UI、Rich Client wire、协议和具体服务实现 | | BitFun 与插件接口 | `PluginRuntimeClient`、安全模块、产品组装、生态适配器 | 来源、能力、Hook 变换、界面贡献、诊断 | 最终权限、工具结果、审计和内核状态 | | 插件运行时接口 | Runtime、执行层、产品组装、`PluginRuntimeClient` | 请求身份、期限、响应校验和诊断 | SDK/UI 对象、生态原始对象和进程句柄 | | 外部生态兼容接口 | 来源管理、能力模块、`PluginRuntimeClient`、Plugin Host | 发现、顺序、参数、诊断和明确映射 | 跨生态任意数据、兄弟适配器依赖和外部 CLI 前置依赖 | @@ -465,16 +478,18 @@ client 或未来 CLI/HarmonyOS 计划,不能证明同名 Rust transport adapte ### 3.2 宿主通信契约与 Tauri 薄适配 前后端契约按能力语义归属,不按 Tauri command 名称归属。稳定的请求、响应、状态事实和类型化错误放在对应 -`contracts/*`、Agent Runtime API 或能力归属模块;Tauri、HTTP/WebSocket、CLI/TUI、ACP 与公开 SDK -Host adapter 只负责把各自协议映射到 -这些类型。该规则降低框架耦合,但不要求把每个 Desktop DTO 都搬进共享 crate。 +`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。待评审目标是让 +Desktop GUI、Web UI 和交互式 TUI 复用同一 Rich Client App Server 行为与 wire contract;Tauri 和各 Rich Client Host 负责 +transport、平台能力及生命周期。ACP、Headless CLI、Peer Host 与公开 SDK 继续由各自 adapter 映射到稳定 owner 接口,不因该目标 +复用 App Server wire。该规则降低框架耦合,但不要求把 controller-local Desktop DTO 搬进共享 crate。 | 层 | 允许 | 禁止 | |---|---|---| | 能力归属模块 / Agent Runtime API | 字段明确的请求和响应、状态事实、权限/取消规则、与框架无关的用例方法 | `tauri::State`、`AppHandle`、窗口/菜单对象、command 宏、HTTP/WebSocket/ACP/SDK Host 消息结构 | -| Desktop Tauri adapter | 读取宿主状态、构造稳定请求、调用对应 Agent Runtime API 或归属模块接口、把明确错误转换为 Desktop 协议、投递桌面事件 | 复制业务校验、持有第二份权威状态、把 Tauri 类型传入下层 | +| Desktop Tauri / proposed App Server Host adapter | 当前组装 Tauri adapter;目标组装 transport、注入真实 capability 与平台 provider、管理窗口和桌面生命周期、投递 App Server typed notification 或桌面专属事件 | 复制业务校验、持有第二份权威状态、在目标迁移完成后为同一能力保留第二条 Runtime 旁路、把 Tauri 类型传入下层 | | Server / Remote adapter | 路由鉴权、协议消息、连接生命周期、流量控制与取消转换 | 为同一能力另建业务含义不同的 DTO 或 handler | -| GUI / TUI 消费方 | 依赖入口侧 API interface、稳定读模型或 Agent Runtime API;各自保留渲染状态 | 依赖公开 Python/TypeScript SDK、直接持有平台句柄,或让 React/TUI 状态成为后端契约 | +| GUI / Web / TUI frontend | 当前依赖各自 infrastructure 或 `TuiBackend`;目标依赖生成的 App Server client、稳定读模型和 Host-local capability adapter;各自保留渲染状态 | 在 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 入口。只有 @@ -522,24 +537,66 @@ Desktop command 使用的序列化对象继续留在 `src/apps/desktop`;即使 ## 4. 运行协作细节 -本节在 Process View Level 0 之下展开产品入口、插件调用和平台能力的当前调用链;这些图描述组件协作,不构成新的 4+1 视图。 +本节在 Process View Level 0 之下展开产品入口、插件调用和平台能力。Current 图只描述当前已接线请求路径;Proposed target 图 +描述待评审方向。两者都只描述组件协作,不构成新的 4+1 视图。 -### 4.1 产品入口 +### 4.1 Current product entry paths ```mermaid flowchart LR - Products["GUI · TUI · CLI · Web"] --> Adapter["入口适配器"] - Protocol["ACP · Server · Remote"] --> Adapter - SDK["Agent SDK"] --> SDKHost["SDK Host"] - Adapter --> API["Runtime API"] + Desktop["Desktop GUI"] --> Tauri["Desktop / Tauri adapter"] + Web["Web UI"] --> WebHost["loopback WebSocket App Server"] + TUI["Interactive TUI"] --> Backend["TuiBackend"] + Backend -->|"Embedded"| EmbeddedAS["in-process App Server"] + Backend -->|"--shared"| SharedIPC["private Runtime IPC v17"] + 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 + SharedIPC --> API + Adapter --> API + SDKAdapter --> API + API --> Runtime["共享 Runtime"] +``` + +当前 Embedded TUI 核心路径经过 App Server,Shared TUI 则由 `TuiBackend` compatibility adapter 映射到 private Runtime IPC v17。 +Desktop GUI 尚未完成 App Server 迁移;当前 loopback Web Host 已通过 WebSocket 承载 App Server。Headless CLI/CI、ACP、Peer Host +和 SDK Host 保留独立 adapter。所有路径最终消费同一 Runtime API 或 owner port,部署选择不能进入业务 owner。 + +Server bootstrap 和产品组装只创建对象并注入依赖,不是客户端请求的第二条旁路: + +```mermaid +flowchart LR + Assembly["产品组装"] -. "constructs" .-> Host["Host-owned App Server + transport"] + Assembly -. "constructs" .-> Runtime["Runtime / owner implementations"] + Runtime -. "injects owner ports" .-> Host +``` + +图中的虚线全部表示启动期 composition;业务请求仍只沿前一张 Current 图中的实线进入 Runtime API 或 owner port。 + +### 4.2 Proposed target product entry paths + +```mermaid +flowchart LR + Rich["Desktop GUI · Web UI · Interactive TUI"] --> Host["Rich Client Host"] + Host --> Client["App Server client"] + Client --> Transport["Host-selected transport"] + Transport --> AppServer["App Server"] + Other["Headless CLI · ACP · Peer Host"] --> Adapter["独立入口适配器"] + SDK["Public Agent SDK"] --> SDKHost["SDK Host"] + AppServer --> API["Runtime API / owner ports"] + Adapter --> API SDKHost --> API API --> Runtime["共享 Runtime"] - Assembly["产品组装"] -. "选择" .-> Runtime ``` -入口 adapter 消费同一 Runtime API,部署选择不能进入业务 owner:Embedded 使用进程内强类型调用;Shared 或 SDK Host 才在各自私有 adapter 中执行 transport 封装。GUI、TUI、Headless CLI、ACP 和 SDK 不共享 wire、renderer 或生命周期,也不得为了统一接口而让默认 Embedded 路径承担序列化成本。 +提案目标是让 Desktop GUI、Web UI 和交互式 TUI 复用 App Server 行为与 wire contract,并让 Embedded/Shared 只在 Host 与 +transport 层不同。是否用 Shared App Server 替换 v17,仍取决于鉴权、实例身份、controller/lease、事件恢复、取消、限制、性能和 +回滚门槛;目标图不表示这些能力已经交付。各入口仍各自拥有 renderer、平台能力和生命周期。Headless CLI/CI、ACP、Peer Host 和 +公开 SDK 不共享 App Server wire。 -### 4.2 插件调用 +### 4.3 插件调用 ```mermaid flowchart LR @@ -556,7 +613,7 @@ flowchart LR Adapter["生态 adapter"] --> Provider["能力 Provider"] --> Owner["能力归属模块"] ``` -### 4.3 平台能力 +### 4.4 平台能力 ```mermaid flowchart LR @@ -567,7 +624,8 @@ flowchart LR 关键规则: -- 产品入口先经过自己的 adapter,再消费 Agent Runtime API 和只读视图;公开 SDK 只多一层 SDK Host 跨进程适配。 +- Current 产品请求遵循 4.1 节;4.2 节的 Rich Client App Server 统一路径只有在相应 Host 完成迁移和验证后才成为当前路径。 + 其他产品入口先经过自己的 adapter,再消费 Agent Runtime API、owner port 和只读视图;公开 SDK 只多一层 SDK Host 跨进程适配。 Agent Runtime API 是一组小而明确的用例接口,不是必须实例化的总入口;adapter 可以调用对应归属模块的少量接口, 但不能访问内部状态、绕过既有编排或复制业务规则。任何入口都不直接调用 Plugin Host。 - 插件只进入扩展贡献接口,不直接写内核状态、工具结果、权限结果或审计事实。 @@ -589,7 +647,7 @@ flowchart LR 可以选择下层提供方,但不能依赖 app crate;需要同时被独立应用和嵌入式模式复用的实现必须下沉到可复用 owner, 再由各 app 和 assembly 组合。 -### 4.4 名词与定义归属 +### 4.5 名词与定义归属 全仓人工维护文档、AGENTS、README 和代码注释遵守以下规则: @@ -727,7 +785,7 @@ flowchart TB CLI["CLI / TUI"] --> CliClosure["Core owner feature closure"] ACP["ACP"] --> Parts["Runtime Parts"] SDKHost["SDK Host"] --> Parts - ServerBootstrap["Server agent bootstrap · dormant"] --> Full + ServerBootstrap["Server App Server Host"] --> Full Full --> Coordinator["ConversationCoordinator"] CliClosure --> Coordinator @@ -735,7 +793,9 @@ flowchart TB Ownership["CoreRuntimeOwnership"] -. "first-party composition injects once" .-> Coordinator ``` -当前公开 HTTP Server 不调用 agent bootstrap,因此不创建 Runtime 或 workspace ownership;图中的 Server 节点只记录已有 agent-enabled composition 边界,不能据此宣称 Server Agent API 已交付。 +当前 HTTP Server 调用 agent bootstrap,创建 Embedded Runtime 和 workspace ownership,并把 `/ws` 连接交给 +`BitfunAppServer::serve`。它固定绑定 loopback,只有 Origin allowlist,没有每连接认证和 user/workspace/execution-domain +绑定;因此只能视为本机单用户 App Server Host,不能据此宣称远程、多用户或公开 Server Agent API 已交付。 当前 Peer 运行连接: @@ -756,7 +816,7 @@ flowchart LR | Desktop | 使用 `product-full`;显示外部来源、审批、冲突、诊断和 Host 能力 | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | | CLI / TUI | 使用显式 Core owner feature closure(`agent-runtime`、`canvas-runtime`、`external-sources`、`plugin-runtime`、`ssh-remote`);提供 `/extensions`、统一 `/hooks`(旧 `/hooks_external` 为别名)、`/tools` 和 `/agents`;Claude Code/Codex 命令 Hook 可经显式审阅复制为原生层 | 保持现有 CLI capability plan,但不自动继承 Desktop 后续加入 `product-full` 的能力;生态解析仍在适配器,不启动第二套 Agent Runtime;OpenCode Hook 仍只静态发现;远程能力未接入时不回退本机 | | ACP | 使用 `DeliveryProfile::Acp`、Runtime Parts,以及 `agent-runtime`/`canvas-runtime`/`external-sources`/`ssh-remote` Core owner feature | load 成功后才发布活动状态;close 排空后再卸载;完整历史、Canvas 工具物化、兼容指令来源和配置仍由 Core/ACP 管理 | -| Peer / Server | Server 提供 control/catalog;Peer Host 执行真实工作区操作;当前 HTTP Server 不装配 Agent Runtime | 控制端不替远端发现或执行;旧 Host 明确降级,SSH Remote 未接入时返回不支持;只读 Server 不声明 Runtime ownership | +| Peer / Server | Peer Host 执行真实工作区操作;当前 HTTP Server 使用 `product-full` 组装 Embedded Runtime,并通过 `/ws` 暴露 App Server | 控制端不替远端发现或执行;Server 仅限 loopback 单用户,缺少连接级安全绑定时不扩展到远程/多用户;SSH Remote 未接入时返回不支持 | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | | HarmonyOS 手机 Remote | phone-only ArkTS 远程入口 | 不等于 HarmonyOS PC 本地 Runtime、CLI/TUI 或 GUI | diff --git a/docs/plans/tui-app-server-decoupling-refactor-plan.md b/docs/plans/tui-app-server-decoupling-refactor-plan.md new file mode 100644 index 0000000000..2edbdd5892 --- /dev/null +++ b/docs/plans/tui-app-server-decoupling-refactor-plan.md @@ -0,0 +1,273 @@ +# TUI 与 App Server 解耦重构计划 + +> 状态:Phase 0-2 已完成当前定义的边界、协议基础和核心聊天迁移;Phase 3-5 尚未开始。 +> +> 当前状态基线:2026-08-05,head `e6705251`。 +> +> 本文只记录当前差距、阶段和完成证据。稳定架构约束见相邻架构文档;Phase 0 的历史盘点已失效,不再作为当前能力清单。 + +相关文档: + +- [CLI 产品线设计](../architecture/cli-product-line-design.md) +- [App Server 架构设计](../architecture/app-server-architecture.md) +- [Agent Runtime 部署设计](../architecture/agent-runtime-deployment-design.md) +- [产品架构](../architecture/product-architecture.md) + +## 1. 范围与目标 + +本计划只迁移交互式 TUI 的产品后端调用: + +1. TUI 保留终端输入、状态、渲染和 controller-local effect。 +2. TUI 通过 app-local `TuiBackend` 使用产品后端,不直接依赖 Core、Runtime 实现、Service、全局 singleton 或私有 IPC operation。 +3. Embedded TUI 使用 `AppServerTuiBackend`;Shared TUI 在 Shared App Server 交付前使用 `SharedTuiBackend` compatibility adapter。 +4. App Server 只适配稳定合同,不接管 Runtime、Service 或 Product Domain 的业务所有权。 +5. Headless `exec`、ACP、Peer Host 和公开 SDK 保留各自经评审的 adapter。 + +不在本计划范围内: + +- 重写 Ratatui 状态机或界面布局。 +- 把 App Server 变成通用 Tool/Core RPC。 +- 迁移 Runtime owner 或重新设计产品领域模型。 +- 为旧 Web Server 私有协议建立长期兼容层。 +- 把 clipboard、editor、terminal raw mode 等 controller-local effect 下沉到工作区 Host。 + +## 2. 当前路径与目标路径 + +### 2.1 Current + +当前 head 有两条交互式 TUI 后端路径: + +```text +Embedded TUI + -> TuiAgentClient + -> TuiBackend + -> AppServerTuiBackend + -> AppServerClient + -> private in-memory transport + -> BitfunAppServer + -> Runtime API / owners + +Shared TUI (--shared) + -> TuiAgentClient + -> TuiBackend + -> SharedTuiBackend compatibility adapter + -> private Runtime IPC v17 + -> Shared Runtime process + -> Runtime API / owners +``` + +两条路径统一的是 TUI 可见的行为端口。Shared compatibility adapter 会把 Runtime IPC 的结果和事件映射为 `TuiBackend` 使用的类型,但它没有运行 `BitfunAppServer`,也不是 Shared App Server transport。 + +Phase 3/4 尚未迁移的配置、MCP、Skill、Subagent、Hook、外部来源、Account 和 Worktree 管理面仍可能通过 CLI Host 中的现有 Core/Service compatibility 路径完成。它们是当前剩余差距,不能据 Phase 2 的核心聊天完成状态宣称整个 TUI 已解耦。 + +### 2.2 Proposed target + +若 [App Server 目标架构](../architecture/app-server-architecture.md) 通过评审,交互式 Rich Client 的目标路径为: + +```text +TUI renderer / input / state / local effects + | + v + TuiBackend trait + | + v + AppServerTuiBackend adapter + | + v + AppServerClient + | + Host-selected transport + / \ + in-memory Embedded controlled Shared local + \ / + v + App Server + | + v + Runtime API / Services / Product Domain owners +``` + +Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controller/lease、事件恢复、断连取消、`outcome_unknown`、frame 限制和空闲退出达到行为等价前继续保留。是否最终删除 v17 由等价测试、性能数据、真实 Rich Client 消费方和回滚证据决定,不能只依据 schema 相同或 adapter 已存在。 + +## 3. 当前能力矩阵 + +状态定义: + +- **已交付**:生产 handler/client 已接线,并被当前 Embedded TUI 路径使用。 +- **兼容映射**:Shared TUI 通过 Runtime IPC v17 和 `SharedTuiBackend` 提供等价 TUI 用例,但没有经过 App Server wire。 +- **部分交付**:已有合同或 handler,但 Host 能力、恢复、安全或 TUI 调用路径仍不完整。 +- **未迁移**:当前 TUI 仍使用既有 compatibility owner 路径,或尚无生产接口。 +- **本地保留**:属于 TUI 或 controller-local effect,不迁移。 + +### 3.1 核心聊天与 Session + +| TUI 用例 | Embedded App Server | Shared v17 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 配置管理面仍属 Phase 3 | +| 流失效与重同步 | `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 更新已覆盖;完整目录和默认值仍属 Phase 3 | +| 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 | 已交付或兼容映射 | + +### 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。 + +当前未交付的是跨连接持久化 cursor、历史事件 replay 和断线后的透明 resume。Shared Runtime IPC v17 仍按自己的 lag/closed、断连取消和 controller 隔离规则工作;`SharedTuiBackend` 只为当前 TUI connection 投影单调 cursor,不能把该投影描述为底层 IPC 已有 replay。 + +### 3.3 尚未迁移的管理面 + +| Domain | 当前状态 | Phase 3/4 需要完成 | +| --- | --- | --- | +| Mode/Model 管理 | 当前 Session mode/model 更新已交付;目录、secret-safe CRUD 和 defaults 未形成完整 App Server 用例 | Runtime-resolved catalog、secret-safe mutation、默认值与 availability | +| Skill/Subagent | 仍使用既有 CLI/Core 管理路径 | visible/manageable read model、override/model binding、context reload 触发规则 | +| MCP | 仍使用既有 CLI/Core/Service 路径 | catalog/status、CRUD、restart、approval、conflict 和 events | +| External Source/Tool/Command/Agent | 当前 App Server production fallback 明确不支持旧 external route | owner snapshot、mutation、review、conflict、generation 和 typed events | +| Hooks | 仍使用既有 native/external hook 管理路径 | native overview 与 external import lifecycle;保持两类 Hook 分离 | +| Account/Settings Sync | 尚无 TUI App Server 闭环 | secret-safe auth flow、sync operation identity、冲突、取消和 snapshot recovery | +| Worktree | Session workspace binding 已进入 sync;bind/release/status 管理未迁移 | owner-scoped worktree lifecycle 和 remote unsupported | +| Desktop/Web Host 安全 | WebSocket Host 仅为 loopback 单用户;Desktop 尚未迁移为 App Server Host | Host allowlist、身份/作用域、真实 limits 与平台 capability provider | + +### 3.4 本地保留 + +以下能力不新增 App Server method: + +| 能力 | 所有者 | +| --- | --- | +| Terminal raw/alternate screen/cursor lifecycle | TUI Host | +| Ratatui render/input/mouse/resize/scroll | TUI | +| Composer draft/history/prompt stash | TUI | +| Theme、terminal color、palette、help、key bindings | TUI | +| Clipboard、图片捕获、外部编辑器 | controller-local capability | +| Controller-local copy/export、notification、bell | controller-local capability | + +图片提交仍须转成受限附件 DTO 并进入后端合同。导出到 controller-local 路径是本地 effect;写入工作区或后端 artifact 必须由工作区 owner 提供数据,再由本地 effect 选择保存位置。 + +## 4. Crate 与 ownership + +当前职责拆分如下: + +| 路径 | 职责 | +| --- | --- | +| `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` | `TuiBackend`、Embedded/Shared adapter 选择、transport 和进程生命周期、TUI-local effect | +| Runtime/Service/Product Domain owners | Session、Turn、Permission、Workspace、配置和其他业务权威事实 | + +边界规则: + +- protocol/client 的依赖闭包不得引入 `bitfun-core`、Runtime 实现、Service 实现、UI framework 或 `product-full`。 +- `bitfun-app-server` 可依赖生产 handler 所需的明确 owner feature,但禁止选择 `bitfun-core/product-full`。 +- Host 负责 transport、认证、作用域、真实 capability/limits、平台能力和进程生命周期。 +- handler 只做合同校验、DTO 转换和错误映射,不持有第二份业务权威状态。 +- DTO 提取不代表 Runtime owner 迁移。 + +## 5. 分阶段状态 + +计划状态以完成条件和验证证据为准,不以 method 数量或文件存在为准: + +| 阶段 | 完成条件 | 验证方式 | 当前状态 | Head | +| --- | --- | --- | --- | --- | +| Phase 0:边界 | `TuiBackend`、behavior-light protocol/client crate、source/Cargo guard 已建立 | Core boundary tests 和 dependency checks | 已完成 | `e6705251` | +| Phase 1:协议基础 | initialize/health、typed events、connection-local cursor、resync、稳定错误和 Embedded connection 已接线 | App Server protocol/client/server focused tests | 已完成 | `e6705251` | +| Phase 2:核心聊天 | Embedded 核心用例经 App Server;Shared 经同一 `TuiBackend` 映射 v17;TUI 核心不引用 Runtime SDK/IPC operation | CLI、App Server、Runtime IPC 和 boundary focused tests | 已完成当前定义 | `e6705251` | +| Phase 3:配置管理 | TUI 不再访问 config/registry/MCP compatibility owner;secret-safe typed APIs 完成 | owner tests、App Server contract tests、CLI behavior tests | 未开始 | - | +| Phase 4:外部集成 | External Source、Hook、Account、Worktree 管理面经 typed backend;remote 不回落本机 | owner/remote/security contract tests | 未开始 | - | +| Phase 5:Shared App Server | Shared Host 达到 v17 治理等价,opt-in 双栈验证完成,并有回滚与删除证据 | 跨 transport parity、故障、性能和安全测试 | 未开始,目标待评审 | - | + +### 5.1 Phase 0-2 已交付摘要 + +- `TuiAgentClient`、Startup 和 `ChatMode` 只消费 app-local `TuiBackend`。 +- Embedded Host 在专用 OS 线程的 current-thread Tokio runtime + `LocalSet` 中运行 private `BitfunAppServer`,TUI 保持在原多线程 runtime。 +- `AppServerTuiBackend` 通过正式 `AppServerClient` 和 in-memory transport 完成核心用例。 +- `SharedTuiBackend` 将相同用例映射到 private Runtime IPC v17;TUI client/controller 不引用 IPC operation。 +- 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 能力。 +- capability 声明列出当前注册方法,但 Host-specific availability 和方向性 limits 仍是后续收紧项。 + +### 5.2 Phase 3 + +目标:移除 TUI 对全局 config、registry 和 MCP service 的直接访问。 + +完成条件: + +- 模型、Mode、Skill、Subagent 和 MCP 使用 owner-specific typed APIs。 +- secret 不出现在 read model、日志或 generic config payload 中。 +- capability 由 Host 注入的 provider、授权和健康状态决定。 +- 管理面 unsupported 不静默回退既有直连路径。 + +### 5.3 Phase 4 + +目标:迁移外部来源、Hook、Account、Settings Sync 和 Worktree 管理面。 + +完成条件: + +- mutation 有 identity/revision、stale、取消和 audit 语义。 +- external source 的发现、审批、冲突和运行时可用性保持由既有 owner 管理。 +- native user hooks、compiled-in `post_call_hooks` 和 external hook catalog 保持分离。 +- remote workspace 不支持的能力返回 typed unsupported,不在 controller 本机执行。 + +### 5.4 Phase 5 + +Phase 5 不以“删除 v17”为起点。建议顺序: + +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。 + +保留 private v17 作为稳定终态也是允许的:只要业务用例和 owner 仍统一,物理 wire 不必为了形式统一而提前收敛。 + +## 6. 验证 + +### 6.1 当前 focused commands + +```bash +cargo check -p bitfun-app-server --offline +cargo test -p bitfun-app-server --offline +cargo test -p bitfun-app-server-protocol +cargo test -p bitfun-app-server-client +cargo check -p bitfun-cli +cargo test -p bitfun-cli +pnpm run check:core-boundaries +``` + +按本 PR 的 Phase 2 实施记录,`e6705251` 已通过 CLI、App Server、Runtime IPC、behavior-light interface crates 和 Core boundary 的 focused checks。两条 headless exec Ctrl+C 断言在隔离基线中同样失败,未计为 Phase 2 回归。后续阶段必须在各自 head 重新记录命令结果,不能沿用此处证据。 + +### 6.2 行为等价场景 + +| 场景组 | 当前必须覆盖 | +| --- | --- | +| Chat | create、sync、submit、stream、Permission、UserInput、cancel、steer、shell | +| Session | rename、model/mode、fork、undo/redo、compact、usage、settlement | +| 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 的 TUI behavior parity | + +Shared App Server 实现后,同一 fixture 必须增加 Embedded App Server、Shared App Server 和 v17 rollback 三方验证,直到 v17 被正式保留或删除。 + +## 7. 完成定义 + +只有同时满足以下条件,才能宣布 TUI/App Server 解耦完成: + +1. Phase 3/4 管理面已迁移,或从产品范围明确移除。 +2. TUI 产品请求和订阅只经过 `TuiBackend`,TUI view/reducer 不执行 backend I/O。 +3. protocol/client 和 TUI-facing 依赖闭包不包含 Core、Runtime/Service 实现、`product-full` 或 private IPC operation。 +4. capability、limits、身份和作用域来自真实 Host/transport,而不是通用 protocol 默认值。 +5. 事件、断线、恢复、权限、取消和 unknown outcome 有明确合同与故障测试。 +6. remote workspace 不存在 controller-local fallback。 +7. 重复 DTO、无效 handler 和无生产消费方的旁路已删除。 +8. 若采用 Shared App Server,迁移满足 Phase 5 的双栈、回滚、性能、安全和删除门槛;否则文档明确 v17 是保留的私有 compatibility transport。 diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index f22dfae6bc..ab104949b0 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -32,6 +32,7 @@ const MODULES = [ './core-boundaries/explicit-test-topology.mjs', './core-boundaries/manifest-feature-helpers.mjs', './core-boundaries/self-test.mjs', + './core-boundaries/tui-boundary-ratchet.mjs', './core-boundaries/rules/crate-rules.mjs', './core-boundaries/rules/feature-rules.mjs', './core-boundaries/rules/source-rules.mjs', diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index 942fedad31..fbd68a3717 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -13,6 +13,7 @@ import { crateLayoutRules, cratePathForName, } from './rules/crate-layout.mjs'; +import { checkTuiLegacyBackendRatchet } from './tui-boundary-ratchet.mjs'; import { coreClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, @@ -1117,6 +1118,7 @@ export function runCoreBoundaryCheck() { } checkCrateLayoutRules(); + failures.push(...checkTuiLegacyBackendRatchet(ROOT)); failures.push(...checkCargoDependencyBoundariesSafely({ root: ROOT, crateLayoutRules })); failures.push(...checkAgentRuntimeIntegrationTestTopology(ROOT)); failures.push(...checkCliIntegrationTestTopology(ROOT)); diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index 8834b8b899..47d3c80247 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -31,6 +31,8 @@ export const crateLayoutRules = [ { crateName: 'acp', layer: 'interfaces', path: 'src/crates/interfaces/acp' }, { crateName: 'app-server', layer: 'interfaces', path: 'src/crates/interfaces/app-server' }, + { crateName: 'app-server-client', layer: 'interfaces', path: 'src/crates/interfaces/app-server-client' }, + { crateName: 'app-server-protocol', layer: 'interfaces', path: 'src/crates/interfaces/app-server-protocol' }, { crateName: 'sdk-host', layer: 'interfaces', path: 'src/crates/interfaces/sdk-host' }, { crateName: 'agent-runtime-ipc', layer: 'adapters', path: 'src/crates/adapters/agent-runtime-ipc' }, { crateName: 'ai-adapters', layer: 'adapters', path: 'src/crates/adapters/ai-adapters' }, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index 8b085f5505..ab5288042e 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -47,6 +47,8 @@ export const noCoreDependencyCrates = [ 'tool-call-jsonrepair', 'agent-runtime', 'agent-runtime-ipc', + 'app-server-client', + 'app-server-protocol', 'harness', 'plugin-runtime-client', 'product-capabilities', @@ -145,6 +147,57 @@ export const forbiddenManifestDependencyRules = [ ]; export const lightweightBoundaryRules = [ + { + crateName: 'app-server-protocol', + reason: + 'App Server wire contracts must stay behavior-light and independent of Runtime, Core, services, product assembly, and UI implementations', + forbiddenDeps: [ + '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', + 'bitfun-app-server-client', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + ], + }, + { + crateName: 'app-server-client', + reason: + 'App Server Rich Client support may depend on wire contracts and transport scaffolding but not backend or UI implementations', + forbiddenDeps: [ + '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', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + ], + }, { crateName: 'agent-runtime-ipc', reason: diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 1f0d0d7ac6..c716b558b3 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -1,12 +1,35 @@ // Boundary rules for source ownership, facades, and required owner content. 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', + patterns: [ + { + regex: /features\s*=\s*\[[^\]]*"product-full"/, + message: 'app-server must not select the broad bitfun-core/product-full union', + }, + ], + }, { path: 'src/crates/adapters/agent-runtime-ipc/src/operation.rs', reason: 'agent-runtime-ipc operation scope is frozen to the reviewed Shared TUI slice', patterns: [ { - regex: /^\s+(?!(?:Health|ListAgentModes|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|UndoSession|RedoSession|SearchWorkspaceReferences|WorkspaceReferencesForMessage|GetSessionLineage|InspectLineageSession|CancelLineageSession|WorkspaceDiff|SubmitTurn|SteerTurn|RunUserShellCommand|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|AgentModes|Sessions|SessionCreated|SessionRestored|SessionForked|SessionReverted|SessionLineage|LineageSessionInspection|WorkspaceReferenceSearch|WorkspaceReferences|TurnAccepted|TurnSteered|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|AgentContextReloadRequest|AgentDialogSteerRequest|AgentDialogTurnRequest|AgentMessageWorkspaceReferencesRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionLineageCancellationRequest|AgentSessionLineageInspection|AgentSessionLineageRequest|AgentSessionLineageSnapshot|AgentSessionLineageTranscriptRequest|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionRevertRequest|AgentSessionRevertResult|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|AgentUserShellCommandRequest|AgentWorkspaceReference|AgentWorkspaceReferenceSearchRequest|AgentWorkspaceReferenceSearchResult|SessionTranscript|WorkspaceDiffSnapshot)\b)[A-Z][A-Za-z0-9_]*\b/, + regex: /^\s+(?!(?:Health|ListAgentModes|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|UndoSession|RedoSession|SessionUsage|WaitForSettlement|RecordLocalCommandTurn|SearchWorkspaceReferences|WorkspaceReferencesForMessage|GetSessionLineage|InspectLineageSession|CancelLineageSession|WorkspaceDiff|SubmitTurn|SteerTurn|RunUserShellCommand|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|AgentModes|Sessions|SessionCreated|SessionRestored|SessionForked|SessionReverted|SessionLineage|LineageSessionInspection|WorkspaceReferenceSearch|WorkspaceReferences|TurnAccepted|TurnSteered|TurnCancelled|LocalCommandTurnRecorded|Idle|Processing|Error|Starting|Compacting|Thinking|Streaming|ToolCalling|ToolConfirming|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|RuntimeSessionState|RuntimeSessionProcessingPhase|AgentContextReloadRequest|AgentDialogSteerRequest|AgentDialogTurnRequest|AgentLocalCommandTurnRecordRequest|AgentLocalCommandTurnRecordResult|AgentMessageWorkspaceReferencesRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionLineageCancellationRequest|AgentSessionLineageInspection|AgentSessionLineageRequest|AgentSessionLineageSnapshot|AgentSessionLineageTranscriptRequest|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionRevertRequest|AgentSessionRevertResult|AgentSessionSummary|AgentSessionUsageRequest|AgentTurnCancellationRequest|AgentTurnCancellationResult|AgentTurnSettlementRequest|AgentUserShellCommandRequest|AgentWorkspaceReference|AgentWorkspaceReferenceSearchRequest|AgentWorkspaceReferenceSearchResult|SessionTranscript|SessionUsageReport|WorkspaceDiffSnapshot)\b)[A-Z][A-Za-z0-9_]*\b/, message: 'agent-runtime-ipc may not add archive, replay, observer, general controller-transfer, or other operations beyond the reviewed Shared TUI slice', }, diff --git a/scripts/core-boundaries/rules/tui-boundary-rules.mjs b/scripts/core-boundaries/rules/tui-boundary-rules.mjs new file mode 100644 index 0000000000..fa511d1db0 --- /dev/null +++ b/scripts/core-boundaries/rules/tui-boundary-rules.mjs @@ -0,0 +1,92 @@ +// 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. + +export const tuiLegacyBackendMarkers = [ + 'bitfun_core::', + 'bitfun_agent_runtime::', + 'bitfun_services_', + 'bitfun_runtime_services', + 'bitfun_agent_runtime_ipc', + 'CliAgentRuntimeClient', + 'CliContextReloadClient', + 'CoreAgentRuntimeCompatibility', + 'crate::account::', + 'crate::account_sync::', + 'get_mcp_service', + 'std::fs', + 'tokio::fs', + 'std::process::', + 'tokio::process', + 'reqwest::', +]; + +export const tuiLegacyBackendBudgets = { + 'src/apps/cli/src/modes/chat.rs': { + 'bitfun_core::': 12, + 'bitfun_agent_runtime::': 3, + CoreAgentRuntimeCompatibility: 3, + }, + 'src/apps/cli/src/modes/chat/commands.rs': { 'bitfun_services_': 1 }, + 'src/apps/cli/src/modes/chat/external_editor.rs': { + 'bitfun_services_': 2, + 'std::fs': 3, + 'std::process::': 1, + }, + 'src/apps/cli/src/modes/chat/account.rs': { + 'crate::account::': 11, + 'crate::account_sync::': 5, + }, + 'src/apps/cli/src/modes/chat/external_hooks.rs': { 'bitfun_core::': 8 }, + 'src/apps/cli/src/modes/chat/external_review.rs': { 'bitfun_core::': 5 }, + 'src/apps/cli/src/modes/chat/mcp.rs': { + 'bitfun_core::': 38, + get_mcp_service: 4, + }, + 'src/apps/cli/src/modes/chat/provider_models.rs': { + 'bitfun_core::': 4, + 'crate::account_sync::': 2, + }, + 'src/apps/cli/src/modes/chat/run.rs': { + 'bitfun_core::': 2, + 'bitfun_agent_runtime::': 3, + 'bitfun_services_': 1, + 'crate::account_sync::': 1, + }, + 'src/apps/cli/src/modes/chat/session_lineage.rs': { 'bitfun_agent_runtime::': 1 }, + 'src/apps/cli/src/modes/chat/selection.rs': { + 'bitfun_core::': 7, + 'crate::account::': 2, + }, + 'src/apps/cli/src/modes/chat/tests.rs': { + 'bitfun_core::': 8, + 'bitfun_agent_runtime::': 3, + }, + 'src/apps/cli/src/modes/chat/worktree.rs': { 'bitfun_core::': 2 }, + 'src/apps/cli/src/ui/chat/popups.rs': { + 'bitfun_core::': 1, + 'bitfun_agent_runtime::': 2, + 'crate::account::': 3, + 'crate::account_sync::': 2, + }, + '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, + }, + 'src/apps/cli/src/ui/prompt_command_shell_review.rs': { 'bitfun_core::': 2 }, + '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': { + 'bitfun_core::': 14, + CoreAgentRuntimeCompatibility: 3, + 'crate::account::': 13, + 'crate::account_sync::': 8, + }, + 'src/apps/cli/src/ui/workspace_diff.rs': { 'bitfun_agent_runtime::': 2 }, + 'src/apps/cli/src/ui/workspace_reference.rs': { 'bitfun_agent_runtime::': 1 }, +}; diff --git a/scripts/core-boundaries/tui-boundary-ratchet.mjs b/scripts/core-boundaries/tui-boundary-ratchet.mjs new file mode 100644 index 0000000000..5e5e77ae07 --- /dev/null +++ b/scripts/core-boundaries/tui-boundary-ratchet.mjs @@ -0,0 +1,68 @@ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join, relative } from 'path'; + +import { + tuiLegacyBackendBudgets, + tuiLegacyBackendMarkers, +} from './rules/tui-boundary-rules.mjs'; + +const scanRoots = [ + 'src/apps/cli/src/ui', + 'src/apps/cli/src/modes/chat.rs', + 'src/apps/cli/src/modes/chat', +]; + +function countLiteral(text, marker) { + let count = 0; + let offset = 0; + while ((offset = text.indexOf(marker, offset)) >= 0) { + count += 1; + offset += marker.length; + } + return count; +} + +function walkFiles(dir, visit) { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) { + walkFiles(path, visit); + } else { + visit(path); + } + } +} + +export function checkTuiLegacyBackendRatchet(root) { + const failures = []; + const seenPaths = new Set(); + + for (const scanRoot of scanRoots) { + const path = join(root, ...scanRoot.split('/')); + const visit = (filePath) => { + if (!filePath.endsWith('.rs')) return; + const repoPath = relative(root, filePath).replace(/\\/g, '/'); + if (seenPaths.has(repoPath)) return; + seenPaths.add(repoPath); + const text = readFileSync(filePath, 'utf8'); + + for (const marker of tuiLegacyBackendMarkers) { + const actual = countLiteral(text, marker); + const allowed = tuiLegacyBackendBudgets[repoPath]?.[marker] ?? 0; + if (actual > allowed) { + failures.push({ + 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`, + }); + } + } + }; + + if (statSync(path).isDirectory()) walkFiles(path, visit); + else visit(path); + } + + return failures; +} diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index 7584aaf127..9d7c98b672 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -35,14 +35,17 @@ runtime owner moved. Normal interactive submissions follow: ```text -ChatView -> CliAgentRuntimeClient -> AgentRuntime SDK - -> Core owner -> Session / Agent execution / ToolPipeline +ChatView -> TuiAgentClient -> TuiBackend -> App Server client + -> App Server handler -> Agent Runtime owner -> ToolPipeline ``` -Shared TUI inserts versioned local IPC between `CliAgentRuntimeClient` and the -same Agent Runtime SDK. It must not create a second product implementation. -Side-effecting operations need stable identities, controller/idle rules, -bounded frames, and outcome-unknown handling before a connection can retry. +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. Replacing the physical Shared transport with App Server +Pipe/UDS framing belongs to Phase 5. 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 f7e9c3129e..0fdf26e358 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -38,6 +38,7 @@ bitfun-core = { path = "../../crates/assembly/core", default-features = false, f "ssh-remote", ] } bitfun-events = { path = "../../crates/contracts/events" } +bitfun-core-types = { path = "../../crates/contracts/core-types" } bitfun-acp = { path = "../../crates/interfaces/acp" } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } bitfun-agent-runtime-ipc = { path = "../../crates/adapters/agent-runtime-ipc" } @@ -46,6 +47,9 @@ bitfun-runtime-services = { path = "../../crates/execution/runtime-services" } bitfun-services-core = { path = "../../crates/services/services-core", default-features = false, 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", default-features = false, 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/agent/context_reload_client.rs b/src/apps/cli/src/agent/context_reload_client.rs deleted file mode 100644 index 32c19897a6..0000000000 --- a/src/apps/cli/src/agent/context_reload_client.rs +++ /dev/null @@ -1,212 +0,0 @@ -use anyhow::Result; -use bitfun_agent_runtime_ipc::{RuntimeIpcClient, RuntimeIpcOperation}; -use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; -use bitfun_runtime_ports::AgentContextReloadRequest; - -use super::runtime_client::expect_unit; - -/// CLI-private adapter that keeps context reload identical across Embedded and -/// Shared TUI deployments without expanding the primary Agent Runtime client. -pub(crate) enum CliContextReloadClient { - Embedded(CoreAgentRuntimeCompatibility), - Shared(RuntimeIpcClient), -} - -impl CliContextReloadClient { - pub(crate) fn embedded(compatibility: CoreAgentRuntimeCompatibility) -> Self { - Self::Embedded(compatibility) - } - - pub(crate) fn shared(client: RuntimeIpcClient) -> Self { - Self::Shared(client) - } - - pub(crate) async fn reload(&self, request: AgentContextReloadRequest) -> Result<()> { - match self { - Self::Embedded(compatibility) => compatibility - .reload_session_context(request) - .await - .map_err(|error| anyhow::anyhow!(error.to_string())), - Self::Shared(client) => { - let result = client - .request(RuntimeIpcOperation::ReloadSessionContext { request }) - .await?; - expect_unit(result, "reload_session_context") - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use async_trait::async_trait; - use bitfun_agent_runtime_ipc::{ - RuntimeInstanceIdentity, RuntimeIpcError, RuntimeIpcErrorCode, RuntimeIpcEvent, - RuntimeIpcOperationResult, RuntimeIpcRequestHandler, RuntimeIpcServer, - RuntimeIpcServerConfig, PROTOCOL_VERSION, - }; - use bitfun_runtime_ports::{ - AgentContextReloadTarget, AgentSessionCreateRequest, AgentSessionCreateResult, - }; - use serde_json::Map; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::{Arc, Mutex}; - use std::time::Duration; - use tempfile::tempdir; - use tokio::sync::broadcast; - - struct ReloadHandler { - calls: Mutex>, - reload_calls: AtomicUsize, - events: broadcast::Sender, - } - - impl ReloadHandler { - fn new() -> Self { - let (events, _) = broadcast::channel(4); - Self { - calls: Mutex::new(Vec::new()), - reload_calls: AtomicUsize::new(0), - events, - } - } - } - - #[async_trait] - impl RuntimeIpcRequestHandler for ReloadHandler { - async fn execute( - &self, - operation: RuntimeIpcOperation, - ) -> std::result::Result { - self.calls - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push(operation.clone()); - - match operation { - RuntimeIpcOperation::CreateSession { request } => { - let mut session = AgentSessionCreateResult::new( - "shared-reload-session", - request.session_name, - request.agent_type, - ); - session.workspace_path = request.workspace_path; - Ok(RuntimeIpcOperationResult::SessionCreated { session }) - } - RuntimeIpcOperation::ReloadSessionContext { .. } - if self.reload_calls.fetch_add(1, Ordering::SeqCst) > 0 => - { - Err(RuntimeIpcError { - code: RuntimeIpcErrorCode::Internal, - message: "shared reload failed".to_string(), - }) - } - _ => Ok(RuntimeIpcOperationResult::Unit), - } - } - - fn subscribe_events( - &self, - _session_id: &str, - ) -> std::result::Result, RuntimeIpcError> { - Ok(self.events.subscribe()) - } - } - - #[tokio::test] - async fn shared_reload_sends_the_typed_ipc_request_and_propagates_remote_errors() { - let runtime_root = tempdir().expect("runtime root"); - let workspace = tempdir().expect("workspace"); - let identity = RuntimeInstanceIdentity::for_workspace( - workspace.path(), - "bitfun", - "test", - "context-reload", - PROTOCOL_VERSION, - ) - .expect("runtime identity"); - let handler = Arc::new(ReloadHandler::new()); - let server = RuntimeIpcServer::bind_with_handler( - runtime_root.path(), - identity, - RuntimeIpcServerConfig { - server_version: "context-reload-test".to_string(), - idle_timeout: Duration::from_millis(50), - handshake_timeout: Duration::from_secs(2), - request_timeout: Duration::from_secs(2), - max_connections: 2, - }, - handler.clone(), - ) - .await - .expect("bind shared server"); - let discovery = server.discovery_record().clone(); - let server_task = tokio::spawn(server.serve()); - let ipc_client = RuntimeIpcClient::connect( - runtime_root.path(), - &discovery, - "context-reload-test", - env!("CARGO_PKG_VERSION"), - Duration::from_secs(2), - Duration::from_secs(2), - ) - .await - .expect("connect shared client"); - let created = ipc_client - .request(RuntimeIpcOperation::CreateSession { - request: AgentSessionCreateRequest { - session_name: "Reload test".to_string(), - agent_type: "agentic".to_string(), - workspace_path: Some(workspace.path().to_string_lossy().to_string()), - project_workspace_path: None, - execution_target: None, - workspace_id: None, - remote_connection_id: None, - remote_ssh_host: None, - model_id: None, - metadata: Map::new(), - }, - }) - .await - .expect("create controlled shared session"); - assert!(matches!( - created, - RuntimeIpcOperationResult::SessionCreated { .. } - )); - - let client = CliContextReloadClient::shared(ipc_client.clone()); - let request = AgentContextReloadRequest { - session_id: "shared-reload-session".to_string(), - target: AgentContextReloadTarget::All, - }; - client - .reload(request.clone()) - .await - .expect("reload shared context"); - assert!(handler - .calls - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .contains(&RuntimeIpcOperation::ReloadSessionContext { - request: request.clone(), - })); - - let error = client - .reload(request) - .await - .expect_err("remote reload error must propagate"); - assert!( - error.to_string().contains("shared reload failed"), - "{error}" - ); - - drop(client); - drop(ipc_client); - tokio::time::timeout(Duration::from_secs(2), server_task) - .await - .expect("shared server exits") - .expect("shared server task") - .expect("shared server result"); - } -} diff --git a/src/apps/cli/src/agent/mod.rs b/src/apps/cli/src/agent/mod.rs index a777221952..ff77ce4dad 100644 --- a/src/apps/cli/src/agent/mod.rs +++ b/src/apps/cli/src/agent/mod.rs @@ -3,5 +3,5 @@ /// Session operations use the shared Agent Runtime SDK. Event consumption /// remains in the chat and exec mode loops. pub(crate) mod agentic_system; -pub(crate) mod context_reload_client; pub(crate) mod runtime_client; +pub(crate) mod tui_client; diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 7b5a639f2c..affadd41b8 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -287,7 +287,7 @@ fn same_workspace_location(left: &Path, right: &Path) -> bool { /// CLI-owned client for the portable Agent Runtime SDK. /// Stateless regarding agent_type; callers pass it per call. -pub(crate) struct CliAgentRuntimeClient { +pub(crate) struct ExecAgentRuntimeClient { backend: CliAgentRuntimeBackend, approval_policy: Arc>, workspace_paths: Arc>, @@ -316,7 +316,7 @@ pub(crate) struct CliAgentMode { type SharedBroadcast = Arc>>>; -impl CliAgentRuntimeClient { +impl ExecAgentRuntimeClient { pub(crate) fn new(runtime: &CliRuntimeContext, workspace_path: Option) -> Self { Self { backend: CliAgentRuntimeBackend::Embedded(runtime.agent_runtime().clone()), @@ -743,6 +743,7 @@ impl CliAgentRuntimeClient { workspace_binding, transcript, pending_permissions, + .. } => ( session, transcript, @@ -1270,7 +1271,7 @@ impl CliAgentRuntimeClient { } } -impl CliAgentRuntimeClient { +impl ExecAgentRuntimeClient { pub(crate) async fn ensure_session(&self, agent_type: &str) -> Result { self.ensure_session_with_model(agent_type, None).await } @@ -1972,7 +1973,7 @@ fn unexpected_shared_result(operation: &str) -> anyhow::Error { mod recovery_tests { use bitfun_agent_runtime::sdk::{PortError, PortErrorKind, RuntimeError}; - use super::CliAgentRuntimeClient; + use super::ExecAgentRuntimeClient; #[test] fn session_recovery_requires_structured_not_found_error() { @@ -1981,10 +1982,10 @@ mod recovery_tests { let unrelated_backend_error = RuntimeError::Port(PortError::new(PortErrorKind::Backend, "model not found")); - assert!(CliAgentRuntimeClient::is_session_not_found_error( + assert!(ExecAgentRuntimeClient::is_session_not_found_error( &missing_session )); - assert!(!CliAgentRuntimeClient::is_session_not_found_error( + assert!(!ExecAgentRuntimeClient::is_session_not_found_error( &unrelated_backend_error )); } diff --git a/src/apps/cli/src/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs new file mode 100644 index 0000000000..7d111a0a89 --- /dev/null +++ b/src/apps/cli/src/agent/tui_client.rs @@ -0,0 +1,1151 @@ +//! Interactive TUI session state projected over the App Server backend boundary. + +use std::collections::HashMap; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +use crate::tui_backend::{TuiBackend, TuiBackendError}; +use anyhow::Result; +use async_trait::async_trait; +use bitfun_app_server_client::AppServerEvent; +use bitfun_app_server_protocol::event::EventStreamState; +use bitfun_app_server_protocol::tui::*; +use bitfun_core_types::SessionUsageReport; +use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; +use bitfun_product_domains::tool_permissions::{ + PermissionReply, PermissionRequest, PermissionRequestEvent, +}; +use bitfun_runtime_ports::{ + put_agent_workspace_references, AgentContextReloadRequest, AgentDialogSteerRequest, + AgentDialogTurnExecution, AgentDialogTurnRequest, AgentInputAttachment, + AgentLocalCommandTurnRecordRequest, 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, +}; +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, +} + +#[async_trait] +pub(crate) trait TuiHostCapabilities: Send + Sync { + async fn available_agent_modes( + &self, + session_id: Option, + workspace: PathBuf, + ) -> Result>; +} + +#[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, + } + } + + fn execution(&self) -> PathBuf { + self.execution + .clone() + .or_else(|| self.project.clone()) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")) + } + + 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) struct TuiAgentClient { + backend: Arc, + host: Arc, + shared: bool, + approval_policy: Arc>, + workspace_paths: Arc>, + session_id: Arc>>, + current_turn_id: Arc>>, + agent_events: Arc>>>, + permission_events: Arc>>>, + pending_permissions: Arc>>, +} + +impl TuiAgentClient { + pub(crate) fn new( + backend: Arc, + host: Arc, + workspace_path: Option, + shared: bool, + approval_policy: CliApprovalPolicy, + ) -> Self { + let (agent_sender, _) = broadcast::channel(256); + let (permission_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 pending_permissions = Arc::new(RwLock::new(HashMap::new())); + spawn_event_bridge( + backend.subscribe_events(), + agent_sender, + permission_sender, + agent_events.clone(), + permission_events.clone(), + pending_permissions.clone(), + ); + Self { + backend, + host, + 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, + pending_permissions, + } + } + + pub(crate) fn is_shared(&self) -> bool { + self.shared + } + + pub(crate) async fn available_agent_modes(&self) -> Result> { + self.host + .available_agent_modes( + self.session_id.lock().await.clone(), + self.workspace_path_buf(), + ) + .await + } + + pub(crate) fn subscribe_events(&self) -> Result> { + shared_receiver( + &self.agent_events, + "App Server agent event stream is unavailable", + ) + } + + pub(crate) fn subscribe_permission_requests( + &self, + ) -> Result> { + shared_receiver( + &self.permission_events, + "App Server permission event stream is unavailable", + ) + } + + pub(crate) fn pending_permission_requests(&self) -> Result> { + Ok(self + .pending_permissions + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .values() + .cloned() + .collect()) + } + + pub(crate) async fn respond_permission( + &self, + 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(()) + } + + pub(crate) async fn record_completed_local_command_turn( + &self, + request: AgentLocalCommandTurnRecordRequest, + ) -> Result<()> { + self.backend + .record_local_command_turn(RecordLocalCommandTurnRequest(request)) + .await?; + Ok(()) + } + + 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() + } + + pub(crate) fn workspace_path_string(&self) -> String { + self.workspace_path_buf().to_string_lossy().to_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 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) + } + + 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) + } + + pub(crate) async fn inspect_lineage_session( + &self, + root_session_id: &str, + 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, + }, + )) + .await + .map(|response| response.0) + .map_err(SessionOperationError::backend) + } + + pub(crate) async fn cancel_lineage_session( + &self, + root_session_id: &str, + 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) + } + + 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(), + }) + } + + 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) + } + + pub(crate) async fn update_session_model( + &self, + 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(), + })) + .await + .map(|_| ()) + .map_err(SessionOperationError::backend) + } + + pub(crate) async fn rename_session( + &self, + 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) + } + + pub(crate) async fn update_session_mode( + &self, + 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) + } + + pub(crate) async fn fork_current_session( + &self, + before_turn_id: Option<&str>, + ) -> Result<( + AgentSessionSummary, + 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)) + } + + 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) + } + + 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) + } + + pub(crate) async fn generate_session_usage_report( + &self, + request: AgentSessionUsageRequest, + ) -> Result { + Ok(self + .backend + .session_usage(SessionUsageRequest(request)) + .await? + .0) + } + + pub(crate) async fn reload_context(&self, request: AgentContextReloadRequest) -> Result<()> { + self.backend + .reload_context(ReloadContextRequest(request)) + .await?; + Ok(()) + } + + pub(crate) async fn wait_for_turn_settlement( + &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 + } + + pub(crate) async fn ensure_session_with_model( + &self, + 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 + } + + async fn create_session( + &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) + } + + 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()) + } + } + } + + pub(crate) async fn send_message_with_context( + &self, + message: String, + workspace_references: Vec, + attachments: Vec, + agent_type: &str, + ) -> Result { + self.submit_dialog_turn( + message, + None, + workspace_references, + attachments, + AgentDialogTurnExecution::Standard, + agent_type, + ) + .await + } + + pub(crate) async fn send_external_subagent_command( + &self, + prompt: String, + original_command: String, + ecosystem_id: String, + logical_id: String, + agent_type: &str, + ) -> Result { + self.submit_dialog_turn( + prompt, + Some(original_command), + Vec::new(), + Vec::new(), + AgentDialogTurnExecution::FreshExternalSubagent { + 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(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()) + } + } + } + + pub(crate) async fn steer_current_turn( + &self, + content: String, + display_content: Option, + ) -> Result { + let session_id = self.require_session_id().await?; + let turn_id = self + .current_turn_id + .lock() + .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, + })) + .await? + .steering_id) + } + + pub(crate) async fn run_user_shell_command( + &self, + 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()) + } + } + } + + 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) + } + + pub(crate) async fn workspace_references_for_message( + &self, + session_id: String, + message_id: String, + ) -> Result> { + Ok(self + .backend + .message_references(MessageReferencesRequest( + AgentMessageWorkspaceReferencesRequest { + session_id, + message_id, + }, + )) + .await? + .0) + } + + 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(()) + } + + pub(crate) async fn submit_user_answers( + &self, + tool_id: &str, + answers: serde_json::Value, + ) -> Result<()> { + self.backend + .submit_user_answers(SubmitUserAnswersRequest { + tool_id: tool_id.to_string(), + answers, + }) + .await?; + Ok(()) + } + + async fn require_session_id(&self) -> Result { + self.session_id + .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)), + ); + } +} + +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, + agent_owner: Arc>>>, + permission_owner: Arc>>>, + 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::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; + }); +} + +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(), + }); + } + } + 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") + ) +} diff --git a/src/apps/cli/src/embedded_app_server.rs b/src/apps/cli/src/embedded_app_server.rs new file mode 100644 index 0000000000..2652598522 --- /dev/null +++ b/src/apps/cli/src/embedded_app_server.rs @@ -0,0 +1,142 @@ +//! Private in-process App Server assembly for the Embedded interactive TUI. + +use std::sync::Arc; + +use crate::tui_backend::{AppServerTuiBackend, TuiBackend}; +use anyhow::{Context, Result}; +use bitfun_app_server::{BitfunAppRuntime, BitfunAppServer}; +use bitfun_app_server_protocol::app::{ClientInfo, HealthStatus, InitializeRequest}; +use bitfun_app_server_protocol::PROTOCOL_VERSION; + +use crate::agent::tui_client::{TuiAgentMode, TuiHostCapabilities}; +use crate::runtime::CliRuntimeContext; + +pub(crate) struct EmbeddedAppServerHost { + backend: Arc, + shutdown_tx: Option>, + server_thread: Option>, +} + +pub(crate) struct EmbeddedTuiHostCapabilities; + +#[async_trait::async_trait] +impl TuiHostCapabilities for EmbeddedTuiHostCapabilities { + async fn available_agent_modes( + &self, + _session_id: Option, + workspace: std::path::PathBuf, + ) -> Result> { + if let Err(error) = + bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( + &workspace, + )) + .await + { + tracing::warn!("Failed to initialize external agent sources: {error}"); + } + let registry = bitfun_core::agentic::agents::get_agent_registry(); + Ok(registry + .get_modes_info_for_workspace(Some(&workspace), true) + .await + .into_iter() + .map(|mode| TuiAgentMode { + id: mode.id, + description: mode.description, + model_id: mode.model, + is_external: mode.source == bitfun_core::agentic::agents::AgentSource::External, + }) + .collect()) + } +} + +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 (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server_thread = std::thread::Builder::new() + .name("bitfun-embedded-app-server".to_string()) + .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).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 fba946ec17..6b223cee9a 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -19,6 +19,7 @@ mod config; mod daemon; mod diagnostics; mod dispatch; +mod embedded_app_server; mod hook_import; mod logging; mod management; @@ -34,7 +35,9 @@ mod root_handlers; mod runtime; mod self_update; mod shared_runtime; +mod shared_tui_backend; mod terminal_attention; +mod tui_backend; mod ui; use anyhow::{anyhow, Result}; @@ -43,8 +46,7 @@ use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, OnceLock}; -use agent::context_reload_client::CliContextReloadClient; -use agent::runtime_client::CliAgentRuntimeClient; +use agent::tui_client::{TuiAgentClient, TuiHostCapabilities}; use config::CliConfig; use hook_import::HookAction; use mcp_import::{McpImportCommand, McpImportOutputFormat}; @@ -884,23 +886,51 @@ async fn run_interactive( .await?, ) }; - let (agent, context_reload) = if let Some(runtime) = &runtime { - ( - Arc::new(CliAgentRuntimeClient::new( - runtime.as_ref(), - Some(workspace_path.clone()), - )), - CliContextReloadClient::embedded(runtime.compatibility().clone()), - ) + 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(); + let host: Arc = + Arc::new(embedded_app_server::EmbeddedTuiHostCapabilities); + Arc::new(TuiAgentClient::new( + backend, + host, + Some(workspace_path.clone()), + false, + runtime.approval_policy(), + )) } else { let client = shared_runtime::connect_or_start(&workspace_path).await?; - ( - Arc::new(CliAgentRuntimeClient::new_shared( - client.clone(), - Some(workspace_path.clone()), - )), - CliContextReloadClient::shared(client), - ) + let backend: Arc = + Arc::new(shared_tui_backend::SharedTuiBackend::new(client.clone())); + let host: Arc = + Arc::new(shared_tui_backend::SharedTuiHostCapabilities::new(client)); + 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, + host, + Some(workspace_path.clone()), + true, + runtime::approval::CliApprovalPolicy::Ask, + )) }; let compatibility = runtime .as_ref() @@ -976,14 +1006,7 @@ 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, - context_reload, - compatibility, - ); + let mut chat_mode = ChatMode::new(config, agent_type, workspace, agent, compatibility); if let Some(session_id) = restore_session_id { chat_mode = chat_mode.with_restore_session(session_id); } @@ -1387,12 +1410,17 @@ 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 agent = Arc::new(CliAgentRuntimeClient::new( - runtime.as_ref(), + let embedded_app_server = embedded_app_server::EmbeddedAppServerHost::start(&runtime).await?; + let host: Arc = + Arc::new(embedded_app_server::EmbeddedTuiHostCapabilities); + let agent = Arc::new(TuiAgentClient::new( + embedded_app_server.backend(), + host, Some(workspace_path), + false, + runtime.approval_policy(), )); let compatibility = runtime.compatibility().clone(); - let context_reload = CliContextReloadClient::embedded(compatibility.clone()); let sessions = agent.list_sessions().await?; let agent_type = sessions .iter() @@ -1405,15 +1433,8 @@ async fn run_interactive_with_session( ) })?; - let mut chat_mode = ChatMode::new( - config, - agent_type, - workspace, - agent, - context_reload, - Some(compatibility), - ) - .with_restore_session(session_id); + let mut chat_mode = ChatMode::new(config, agent_type, workspace, agent, Some(compatibility)) + .with_restore_session(session_id); let run_result = chat_mode.run(Some(terminal)); shutdown_mcp_servers().await; diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 17834010c2..4fb56eaa9c 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -20,12 +20,14 @@ use std::sync::{ use std::time::{Duration, Instant}; use tokio::sync::broadcast::error::TryRecvError; -use bitfun_agent_runtime::sdk::{ +use bitfun_core_types::SessionUsageReport; +use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; +use bitfun_runtime_ports::{ AgentLocalCommandTurnRecordRequest, AgentSessionComposerUpdate, AgentSessionLineageEntry, AgentSessionLineageInspection, AgentSessionLineageSnapshot, AgentSessionUsageRequest, - AgentTurnCancellationResult, SessionTranscript, SessionUsageReport, + AgentTurnCancellationResult, AgentWorkspaceReferenceSearchResult, SessionTranscript, + WorkspaceDiffSnapshot, }; -use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; use resize::ResizeRedrawState; use crate::actions::{ @@ -34,8 +36,7 @@ use crate::actions::{ ActionState, ResolvedKeymap, IMAGE_ATTACHMENTS_REQUIRE_MESSAGE, SHARED_TUI_EMBEDDED_HANDOFF, SHARED_TUI_HELP_NOTE, }; -use crate::agent::context_reload_client::CliContextReloadClient; -use crate::agent::runtime_client::{CliAgentMode, CliAgentRuntimeClient, SessionOperationError}; +use crate::agent::tui_client::{SessionOperationError, TuiAgentClient, TuiAgentMode}; use crate::chat_state::{ChatState, ModelTokenUsageSnapshot}; use crate::config::CliConfig; use crate::ui::agent_selector::{AgentItem, AgentSelectorAction}; @@ -356,15 +357,12 @@ struct PendingSessionOperation { struct PendingWorkspaceReferenceSearch { generation: u64, query: String, - handle: tokio::task::JoinHandle< - std::result::Result, - >, + handle: + tokio::task::JoinHandle>, } struct PendingWorkspaceDiff { - handle: tokio::task::JoinHandle< - std::result::Result, - >, + handle: tokio::task::JoinHandle>, } enum LineageInspectionTaskError { @@ -455,6 +453,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(_, _)) } @@ -508,8 +512,7 @@ pub(crate) struct ChatMode { agent_type: String, workspace: Option, local_cwd: std::path::PathBuf, - agent: Arc, - context_reload: CliContextReloadClient, + agent: Arc, compatibility: Option, /// User-level default resolved from shared config for this TUI run. auto_approve_ask_default: bool, @@ -595,8 +598,7 @@ impl ChatMode { config: CliConfig, agent_type: String, workspace: Option, - agent: Arc, - context_reload: CliContextReloadClient, + agent: Arc, compatibility: Option, ) -> Self { let keymap = ResolvedKeymap::new(&config.shortcuts); @@ -607,7 +609,6 @@ impl ChatMode { workspace, local_cwd: std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), agent, - context_reload, compatibility, auto_approve_ask_default: false, auto_approve_ask_override: None, diff --git a/src/apps/cli/src/modes/chat/capabilities.rs b/src/apps/cli/src/modes/chat/capabilities.rs index 87d64d6b2e..741e862449 100644 --- a/src/apps/cli/src/modes/chat/capabilities.rs +++ b/src/apps/cli/src/modes/chat/capabilities.rs @@ -22,7 +22,7 @@ impl ChatMode { target, }; let outcome = - tokio::task::block_in_place(|| rt_handle.block_on(self.context_reload.reload(request))); + tokio::task::block_in_place(|| rt_handle.block_on(self.agent.reload_context(request))); match outcome { Ok(_) => { diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 76635759f9..2f94faf217 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -151,6 +151,10 @@ 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(); @@ -531,7 +535,7 @@ impl ChatMode { let mut event_rx = self .agent .subscribe_events() - .map_err(|error| anyhow::anyhow!(error.into_message()))?; + .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| { @@ -646,7 +650,7 @@ impl ChatMode { if let Some(receiver) = permission_rx.as_mut() { for _ in 0..4 { match receiver.try_recv() { - Ok(bitfun_agent_runtime::sdk::PermissionRequestEvent::Asked { + Ok(bitfun_product_domains::tool_permissions::PermissionRequestEvent::Asked { request, }) if crate::runtime::approval::permission_request_targets_session( &request, @@ -661,11 +665,11 @@ impl ChatMode { needs_redraw = true; } } - Ok(bitfun_agent_runtime::sdk::PermissionRequestEvent::Replied { + Ok(bitfun_product_domains::tool_permissions::PermissionRequestEvent::Replied { request_id, .. }) - | Ok(bitfun_agent_runtime::sdk::PermissionRequestEvent::Cancelled { + | Ok(bitfun_product_domains::tool_permissions::PermissionRequestEvent::Cancelled { request_id, .. }) => { @@ -701,7 +705,7 @@ impl ChatMode { Err(error) => { let mut failure = format!( "Shared Runtime permission state could not be resynchronized: {}", - error.into_message() + error ); let agent = self.agent.clone(); if let Err(error) = tokio::task::block_in_place(|| { diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index b3a6c6387a..f86be11290 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -386,7 +386,7 @@ impl ChatMode { chat_view.set_status(Some(format!("Theme set to: {}", theme.id))); } - fn get_mode_agents(&self, rt_handle: &tokio::runtime::Handle) -> Vec { + fn get_mode_agents(&self, rt_handle: &tokio::runtime::Handle) -> Vec { tokio::task::block_in_place(|| { rt_handle .block_on(self.agent.available_agent_modes()) diff --git a/src/apps/cli/src/modes/chat/session_lineage.rs b/src/apps/cli/src/modes/chat/session_lineage.rs index d138ce2fee..015d61e619 100644 --- a/src/apps/cli/src/modes/chat/session_lineage.rs +++ b/src/apps/cli/src/modes/chat/session_lineage.rs @@ -1054,11 +1054,11 @@ fn lineage_sibling_session_id( #[cfg(test)] mod session_lineage_tests { - use bitfun_agent_runtime::sdk::{ + use bitfun_events::AgenticEvent; + use bitfun_runtime_ports::{ AgentSessionLifecycleStatus, AgentSessionLineageEntry, AgentSessionLineageInspection, AgentSessionLineageSnapshot, SessionTranscript, TranscriptContent, TranscriptMessage, }; - use bitfun_events::AgenticEvent; use crate::chat_state::{FlowItem, MessageRole}; use std::collections::{BTreeMap, HashMap, VecDeque}; diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 9d326a3d07..c090b470e3 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -2670,12 +2670,12 @@ mod tests { let mut referenced = plain.clone(); referenced .workspace_references - .push(bitfun_agent_runtime::sdk::AgentWorkspaceReference { + .push(bitfun_runtime_ports::AgentWorkspaceReference { path: "src/lib.rs".to_string(), - kind: bitfun_agent_runtime::sdk::AgentWorkspaceReferenceKind::File, + kind: bitfun_runtime_ports::AgentWorkspaceReferenceKind::File, start_line: None, end_line: None, - source: bitfun_agent_runtime::sdk::AgentWorkspaceReferenceSourceRange { + source: bitfun_runtime_ports::AgentWorkspaceReferenceSourceRange { start: 0, end: 11, value: "@src/lib.rs".to_string(), diff --git a/src/apps/cli/src/modes/exec/lifecycle.rs b/src/apps/cli/src/modes/exec/lifecycle.rs index b757c3ee2b..f5894ffb07 100644 --- a/src/apps/cli/src/modes/exec/lifecycle.rs +++ b/src/apps/cli/src/modes/exec/lifecycle.rs @@ -19,7 +19,7 @@ use bitfun_agent_tools::effective_tool_invocation; use bitfun_events::{AgenticEvent, ToolEventIdentity}; use tokio::time::Instant; -use crate::agent::runtime_client::CliAgentRuntimeClient; +use crate::agent::runtime_client::ExecAgentRuntimeClient; use crate::config::CliConfig; use crate::diagnostics::{ cli_error_code, emit_exit_diagnostic, user_facing_error_message, ExitContext, ExitKind, @@ -472,7 +472,7 @@ pub(crate) struct ExecMode { config: CliConfig, message: String, agent_type: String, - agent: Arc, + agent: Arc, runtime: Arc, pub(super) workspace_path: Option, /// Git tree captured before execution so committed agent changes remain @@ -509,7 +509,7 @@ impl ExecMode { | crate::runtime::approval::CliApprovalPolicy::DisableAuto | crate::runtime::approval::CliApprovalPolicy::Reject => ExecApprovalMode::Reject, }; - let agent = Arc::new(CliAgentRuntimeClient::new( + let agent = Arc::new(ExecAgentRuntimeClient::new( runtime.as_ref(), workspace_path.clone(), )); @@ -996,16 +996,15 @@ impl ExecMode { Err(error) => (Vec::new(), Err(error)), }; for envelope in buffered_events { - self - .project_exec_nonterminal_event( - &envelope, - &session_id, - &turn_id, - &mut assistant_text, - &mut usage, - &mut total_tool_calls, - ) - .await?; + self.project_exec_nonterminal_event( + &envelope, + &session_id, + &turn_id, + &mut assistant_text, + &mut usage, + &mut total_tool_calls, + ) + .await?; } match resolve_cancelled_turn_observation( observed_terminal, diff --git a/src/apps/cli/src/runtime/mod.rs b/src/apps/cli/src/runtime/mod.rs index 2ceec6f876..05bc062cc4 100644 --- a/src/apps/cli/src/runtime/mod.rs +++ b/src/apps/cli/src/runtime/mod.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, Result}; -use bitfun_agent_runtime::sdk::AgentRuntime; +use bitfun_agent_runtime::sdk::{AgentEventSource, AgentRuntime}; use bitfun_core::agentic::system::AgenticSystem; use bitfun_core::product_assembly::{ProductAssemblyPlan, ProductServiceCapabilityAvailability}; use bitfun_core::product_runtime::{ @@ -128,6 +128,10 @@ impl CliRuntimeContext { &self.agent_runtime } + pub(crate) fn agent_event_source(&self) -> AgentEventSource { + self._agent_event_queue_owner.runtime_source() + } + pub(crate) fn compatibility(&self) -> &CoreAgentRuntimeCompatibility { &self.compatibility } diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 4ad050d292..3c2cad6a38 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -10,7 +10,8 @@ use bitfun_agent_runtime_ipc::{ DiscoveryStore, RuntimeAgentModeSummary, RuntimeInstanceIdentity, RuntimeIpcClient, RuntimeIpcError, RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeIpcRequestHandler, RuntimeIpcServer, RuntimeIpcServerConfig, - RuntimeIpcStreamInvalidationReason, RuntimeSessionRenameRequest, PROTOCOL_VERSION, + RuntimeIpcStreamInvalidationReason, RuntimeSessionProcessingPhase, RuntimeSessionRenameRequest, + RuntimeSessionState, PROTOCOL_VERSION, }; use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::runtime_ownership::CoreRuntimeOwnership; @@ -309,6 +310,7 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .await?; Ok(RuntimeIpcOperationResult::SessionRestored { session: restored.session, + state: runtime_session_state(restored.state), workspace_binding, transcript, pending_permissions, @@ -418,6 +420,19 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .await .map(|revert| RuntimeIpcOperationResult::SessionReverted { revert }) .map_err(runtime_ipc_error), + RuntimeIpcOperation::SessionUsage { request } => self + .runtime + .generate_session_usage(request) + .await + .map(|usage| RuntimeIpcOperationResult::SessionUsage { usage }) + .map_err(runtime_ipc_error), + RuntimeIpcOperation::WaitForSettlement { request } => { + self.runtime + .wait_for_turn_settlement(request) + .await + .map_err(runtime_ipc_error)?; + Ok(RuntimeIpcOperationResult::Unit) + } RuntimeIpcOperation::SearchWorkspaceReferences { request } => self .runtime .search_workspace_references(request) @@ -578,6 +593,12 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .remove(&request.tool_id); Ok(RuntimeIpcOperationResult::Unit) } + RuntimeIpcOperation::RecordLocalCommandTurn { request } => self + .runtime + .record_completed_local_command_turn(request) + .await + .map(|record| RuntimeIpcOperationResult::LocalCommandTurnRecorded { record }) + .map_err(runtime_ipc_error), } } @@ -589,6 +610,31 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { } } +fn runtime_session_state(state: bitfun_agent_runtime::sdk::SessionState) -> RuntimeSessionState { + use bitfun_agent_runtime::sdk::{ProcessingPhase, SessionState}; + + match state { + SessionState::Idle => RuntimeSessionState::Idle, + SessionState::Processing { + current_turn_id, + phase, + } => RuntimeSessionState::Processing { + current_turn_id, + phase: match phase { + ProcessingPhase::Starting => RuntimeSessionProcessingPhase::Starting, + ProcessingPhase::Compacting => RuntimeSessionProcessingPhase::Compacting, + ProcessingPhase::Thinking => RuntimeSessionProcessingPhase::Thinking, + ProcessingPhase::Streaming => RuntimeSessionProcessingPhase::Streaming, + ProcessingPhase::ToolCalling => RuntimeSessionProcessingPhase::ToolCalling, + ProcessingPhase::ToolConfirming => RuntimeSessionProcessingPhase::ToolConfirming, + }, + }, + SessionState::Error { error, recoverable } => { + RuntimeSessionState::Error { error, recoverable } + } + } +} + fn owned_session_rename_request( workspace: &Path, request: RuntimeSessionRenameRequest, diff --git a/src/apps/cli/src/shared_tui_backend.rs b/src/apps/cli/src/shared_tui_backend.rs new file mode 100644 index 0000000000..26816bbfa6 --- /dev/null +++ b/src/apps/cli/src/shared_tui_backend.rs @@ -0,0 +1,937 @@ +//! CLI Host compatibility adapter from the private Shared Runtime IPC to TUI v2. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use crate::tui_backend::{TuiBackend, TuiBackendError}; +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_client::AppServerEvent; +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::tui::*; +use bitfun_app_server_protocol::{MIN_PROTOCOL_VERSION, PROTOCOL_VERSION}; +use bitfun_runtime_ports::{ + AgentSessionCompactionResult, AgentSessionForkResult, AgentUserShellCommandResult, +}; +use tokio::sync::broadcast; + +use crate::agent::tui_client::{TuiAgentMode, TuiHostCapabilities}; + +const EVENT_BUFFER: usize = 256; + +pub(crate) struct SharedTuiBackend { + client: RuntimeIpcClient, + current_session_id: Arc>>, + events: broadcast::Sender, +} + +pub(crate) struct SharedTuiHostCapabilities { + client: RuntimeIpcClient, +} + +impl SharedTuiHostCapabilities { + pub(crate) fn new(client: RuntimeIpcClient) -> Self { + Self { client } + } +} + +#[async_trait] +impl TuiHostCapabilities for SharedTuiHostCapabilities { + async fn available_agent_modes( + &self, + session_id: Option, + _workspace: std::path::PathBuf, + ) -> anyhow::Result> { + match self + .client + .request(RuntimeIpcOperation::ListAgentModes { session_id }) + .await? + { + RuntimeIpcOperationResult::AgentModes { modes } => Ok(modes + .into_iter() + .map(|mode| TuiAgentMode { + id: mode.id, + description: mode.description, + model_id: mode.model_id, + is_external: mode.is_external, + }) + .collect()), + other => Err(anyhow::anyhow!( + "Shared Runtime returned an unexpected mode catalog result: {other:?}" + )), + } + } +} + +impl SharedTuiBackend { + pub(crate) fn new(client: RuntimeIpcClient) -> 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); + Self { + client, + 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 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) + } +} + +#[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(), + TransportLimits { + max_frame_bytes: 16 * 1024 * 1024, + event_buffer_capacity: EVENT_BUFFER as u32, + }, + )) + } + + 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); + 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()); + 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 }) + .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 record_local_command_turn( + &self, + request: RecordLocalCommandTurnRequest, + ) -> Result { + match self + .request(RuntimeIpcOperation::RecordLocalCommandTurn { request: request.0 }) + .await? + { + RuntimeIpcOperationResult::LocalCommandTurnRecorded { record } => { + Ok(RecordLocalCommandTurnResponse(record)) + } + other => Err(unexpected("record_local_command_turn", other)), + } + } + + 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 {}) + } +} + +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, .. } => { + self.set_current_session(session.session_id.clone()); + Ok(ForkSessionResponse(AgentSessionForkResult { + session_id: session.session_id, + session_name: session.session_name, + agent_type: session.agent_type, + })) + } + other => Err(unexpected("fork_session", other)), + } + } +} + +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, + } +} + +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() -> Vec { + [ + ( + "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/recordLocalCommandTurn", + "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() +} + +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 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}; + + 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 new file mode 100644 index 0000000000..5df77b92c6 --- /dev/null +++ b/src/apps/cli/src/tui_backend.rs @@ -0,0 +1,420 @@ +//! CLI-local App Server boundary for the interactive TUI. + +use async_trait::async_trait; +use bitfun_app_server_client::{AppServerClient, AppServerEvent, ClientError}; +use bitfun_app_server_protocol::app::{HealthResponse, InitializeRequest, InitializeResponse}; +use bitfun_app_server_protocol::tui::*; + +#[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, +} + +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 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 record_local_command_turn( + &self, + request: RecordLocalCommandTurnRequest, + ) -> 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; +} + +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 { + self.client + .initialize(request) + .await + .map_err(|error| TuiBackendError { + message: error.to_string(), + outcome_unknown: false, + }) + } + + async fn health(&self) -> Result { + map(self.client.health().await) + } + + fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver { + self.client.subscribe_events() + } + + 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 record_local_command_turn( + &self, + request: RecordLocalCommandTurnRequest, + ) -> Result { + map_client(self.client.record_local_command_turn(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) + } +} + +fn map(result: Result) -> Result { + result.map_err(|error| TuiBackendError { + message: error.to_string(), + outcome_unknown: false, + }) +} + +fn map_client(result: Result) -> Result { + result.map_err(|error| TuiBackendError { + outcome_unknown: matches!(error, ClientError::Timeout(_)), + message: error.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::{TuiEffect, TuiEffectRoute}; + + 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); + } +} diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 4004cd3aff..5aab489abf 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -56,7 +56,7 @@ use bitfun_core::agentic::tools::implementations::skills::{ use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::service::config::GlobalConfigManager; -use crate::agent::runtime_client::{CliAgentMode, CliAgentRuntimeClient}; +use crate::agent::tui_client::{TuiAgentClient, TuiAgentMode}; /// Types of popups that can be shown on the startup page #[derive(Debug, Clone, PartialEq)] @@ -205,7 +205,7 @@ pub(crate) struct StartupPage { theme_preview_original: Option, // ── System context ── - agent: Arc, + agent: Arc, compatibility: Option, // ── State ── @@ -230,7 +230,7 @@ pub(crate) struct StartupPage { impl StartupPage { pub(crate) fn new( config: CliConfig, - agent: Arc, + agent: Arc, compatibility: Option, default_agent: String, workspace: Option, @@ -2453,7 +2453,7 @@ impl StartupPage { self.popup_stack.clear(); } - fn get_mode_agents(&self) -> Vec { + fn get_mode_agents(&self) -> Vec { tokio::task::block_in_place(|| { tokio::runtime::Handle::current() .block_on(self.agent.available_agent_modes()) @@ -2464,7 +2464,7 @@ impl StartupPage { }) } - fn selected_agent_mode(&self) -> Option { + fn selected_agent_mode(&self) -> Option { self.get_mode_agents() .into_iter() .find(|mode| mode.id == self.agent_type) @@ -2565,7 +2565,7 @@ fn resolve_startup_model_id( .or(default_model_id) } -fn should_persist_shared_model_default(mode: Option<&CliAgentMode>) -> bool { +fn should_persist_shared_model_default(mode: Option<&TuiAgentMode>) -> bool { mode.is_some_and(|mode| !mode.is_external) } @@ -2598,13 +2598,13 @@ mod logo_contract_tests { #[test] fn external_or_unknown_startup_modes_do_not_change_the_shared_default() { - let local = CliAgentMode { + let local = TuiAgentMode { id: "agentic".to_string(), description: String::new(), model_id: None, is_external: false, }; - let external = CliAgentMode { + let external = TuiAgentMode { id: "reviewer".to_string(), description: String::new(), model_id: None, 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 a225bdebf1..8d992d631f 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 @@ -257,41 +257,55 @@ fn local_workspace_snapshot_port_does_not_expand_the_agent_runtime_sdk() { } #[test] -fn primary_cli_session_client_uses_only_the_runtime_sdk_boundary() { +fn interactive_tui_session_client_uses_only_the_app_server_boundary() { const AGENT_MODULE: &str = include_str!("../../src/agent/mod.rs"); - const PRIMARY_CLIENT: &str = include_str!("../../src/agent/runtime_client.rs"); + 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 Runtime SDK client boundary" - ); - assert!( - !PRIMARY_CLIENT.contains("CoreAgentRuntimeCompatibility") - && !PRIMARY_CLIENT.contains("compatibility:") - && !PRIMARY_CLIENT.contains("is_turn_processing"), - "the primary CLI/TUI session client must not depend on Core compatibility or state polling" - ); - for sdk_operation in [ - "fork_session", - "generate_session_usage", - "wait_for_turn_settlement", + "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" + ); + assert!( + TUI_BACKEND.contains("pub(crate) trait TuiBackend") + && TUI_BACKEND.contains("AppServerClient") + && !TUI_BACKEND.contains("bitfun_agent_runtime") + && !TUI_BACKEND.contains("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(", ] { assert!( - PRIMARY_CLIENT.contains(sdk_operation), - "primary session client must route {sdk_operation} through the Runtime SDK" + TUI_CLIENT.contains(backend_operation), + "interactive session client must route {backend_operation} through TuiBackend" ); } } #[test] -fn chat_context_reload_keeps_deployment_choice_behind_a_cli_adapter() { +fn chat_context_reload_uses_the_same_tui_backend_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 RELOAD_CLIENT: &str = include_str!("../../src/agent/context_reload_client.rs"); + const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); assert!( - CHAT_MODE.contains("context_reload: CliContextReloadClient"), - "ChatMode must submit reload through one CLI-owned deployment adapter" + !CHAT_MODE.contains("context_reload") + && CHAT_CAPABILITIES.contains("self.agent.reload_context(request)"), + "ChatMode must submit context reload through its existing TUI session client" ); assert!( !CHAT_CAPABILITIES.contains("is_shared()") @@ -300,16 +314,14 @@ fn chat_context_reload_keeps_deployment_choice_behind_a_cli_adapter() { "TUI capability code must not branch context reload by Runtime deployment" ); assert!( - RELOAD_CLIENT.contains(".reload_session_context(request)") - && RELOAD_CLIENT - .contains(".request(RuntimeIpcOperation::ReloadSessionContext { request })"), - "the private adapter must delegate directly to the existing Embedded and Shared owners" + TUI_CLIENT.contains(".reload_context(ReloadContextRequest(request))"), + "the TUI session client must delegate reload to TuiBackend" ); } #[test] -fn primary_cli_runtime_client_covers_interactive_permission_and_local_turn_operations() { - const PRIMARY_CLIENT: &str = include_str!("../../src/agent/runtime_client.rs"); +fn tui_client_covers_interactive_permission_and_local_turn_operations() { + const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); for sdk_operation in [ "subscribe_permission_requests", @@ -318,21 +330,23 @@ fn primary_cli_runtime_client_covers_interactive_permission_and_local_turn_opera "record_completed_local_command_turn", ] { assert!( - PRIMARY_CLIENT.contains(sdk_operation), - "interactive TUI operation {sdk_operation} must stay behind the existing runtime client" + TUI_CLIENT.contains(sdk_operation), + "interactive TUI operation {sdk_operation} must stay behind TuiAgentClient" ); } } #[test] -fn interactive_tui_agent_operations_stay_behind_cli_runtime_client() { +fn interactive_tui_agent_operations_stay_behind_app_server_backend() { 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"); const CHAT_COMMANDS: &str = include_str!("../../src/modes/chat/commands.rs"); const CHAT_INPUT: &str = include_str!("../../src/modes/chat/input.rs"); const CHAT_SELECTION: &str = include_str!("../../src/modes/chat/selection.rs"); - const RUNTIME_CLIENT: &str = include_str!("../../src/agent/runtime_client.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 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"); @@ -352,31 +366,34 @@ fn interactive_tui_agent_operations_stay_behind_cli_runtime_client() { ] { assert!( !source.contains(".agent_runtime()"), - "{path} must route Agent operations through CliAgentRuntimeClient" + "{path} must route Agent operations through TuiAgentClient" ); } assert!( - CHAT_MODE.contains("CliAgentRuntimeClient"), - "interactive chat must retain the existing app-private runtime client facade" + CHAT_MODE.contains("Arc") && STARTUP_PAGE.contains("Arc"), + "interactive chat and startup must use the backend-neutral TUI session client" ); assert!( !CLI_CARGO.contains("bitfun-sdk-host") && CLI_CARGO.contains("bitfun-agent-runtime-ipc"), "Shared TUI must use the private Runtime IPC adapter without making CLI depend on SDK Host" ); assert!( - RUNTIME_CLIENT.contains("RuntimeIpcClient") + SHARED_TUI_BACKEND.contains("RuntimeIpcClient") + && !TUI_CLIENT.contains("RuntimeIpcClient") && !STARTUP_PAGE.contains("RuntimeIpcClient") && !CHAT_MODE.contains("RuntimeIpcClient"), - "Shared IPC must remain behind CliAgentRuntimeClient instead of leaking into TUI controllers" + "Shared IPC must remain in the CLI Host adapter instead of leaking into TUI clients or controllers" ); assert!( - RUNTIME_CLIENT.contains("RuntimeIpcOperation::UpdateSessionMode { request }") + SHARED_TUI_BACKEND + .contains("RuntimeIpcOperation::UpdateSessionMode { request: request.0 }") && 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!( - RUNTIME_CLIENT.contains("RuntimeIpcOperation::UpdateSessionModel { request }") + SHARED_TUI_BACKEND + .contains("RuntimeIpcOperation::UpdateSessionModel { request: request.0 }") && 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" @@ -396,7 +413,7 @@ fn interactive_tui_agent_operations_stay_behind_cli_runtime_client() { ); assert!( CHAT_COMMANDS.matches("if self.agent.is_shared()").count() >= 3 - && RUNTIME_CLIENT.contains("Failed to read Embedded session transcript") + && EMBEDDED_APP_SERVER.contains("AppServerTuiBackend::new(client)") && 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" diff --git a/src/crates/adapters/agent-runtime-ipc/src/lib.rs b/src/crates/adapters/agent-runtime-ipc/src/lib.rs index 7ef31bb826..233d3d5979 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/lib.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/lib.rs @@ -26,8 +26,8 @@ pub use ipc::RuntimeIpcTransportError; pub(crate) use ipc::{LocalIpcEndpoint, LocalIpcListener, LocalIpcStream}; pub use operation::{ RuntimeAgentModeSummary, RuntimeIpcOperation, RuntimeIpcOperationResult, - RuntimeSessionForkRequest, RuntimeSessionRenameRequest, RuntimeSessionRestoreRequest, - RuntimeUserAnswersRequest, + RuntimeSessionForkRequest, RuntimeSessionProcessingPhase, RuntimeSessionRenameRequest, + RuntimeSessionRestoreRequest, RuntimeSessionState, RuntimeUserAnswersRequest, }; pub use protocol::{ HealthResult, InitializeRequest, InitializeResult, RuntimeIpcCapabilities, RuntimeIpcError, diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index 8901f9f7de..15558630cd 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -1,14 +1,16 @@ use bitfun_product_domains::tool_permissions::{PermissionReply, PermissionRequest}; use bitfun_runtime_ports::{ AgentContextReloadRequest, AgentDialogSteerRequest, AgentDialogTurnRequest, + AgentLocalCommandTurnRecordRequest, AgentLocalCommandTurnRecordResult, AgentMessageWorkspaceReferencesRequest, AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionLineageCancellationRequest, AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRevertRequest, AgentSessionRevertResult, - AgentSessionSummary, AgentSessionWorkspaceBinding, AgentTurnCancellationRequest, - AgentTurnCancellationResult, AgentUserShellCommandRequest, AgentWorkspaceReference, - AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, SessionTranscript, + AgentSessionSummary, AgentSessionUsageRequest, AgentSessionWorkspaceBinding, + AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentTurnSettlementRequest, + AgentUserShellCommandRequest, AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, + AgentWorkspaceReferenceSearchResult, SessionTranscript, SessionUsageReport, WorkspaceDiffSnapshot, }; use serde::{Deserialize, Serialize}; @@ -58,6 +60,35 @@ pub struct RuntimeAgentModeSummary { pub is_external: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum RuntimeSessionState { + Idle, + Processing { + current_turn_id: String, + phase: RuntimeSessionProcessingPhase, + }, + Error { + error: String, + recoverable: bool, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeSessionProcessingPhase { + Starting, + Compacting, + Thinking, + Streaming, + ToolCalling, + ToolConfirming, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde( tag = "operation", @@ -107,6 +138,12 @@ pub enum RuntimeIpcOperation { RedoSession { request: AgentSessionRevertRequest, }, + SessionUsage { + request: AgentSessionUsageRequest, + }, + WaitForSettlement { + request: AgentTurnSettlementRequest, + }, SearchWorkspaceReferences { request: AgentWorkspaceReferenceSearchRequest, }, @@ -146,6 +183,9 @@ pub enum RuntimeIpcOperation { SubmitUserAnswers { request: RuntimeUserAnswersRequest, }, + RecordLocalCommandTurn { + request: AgentLocalCommandTurnRecordRequest, + }, } impl RuntimeIpcOperation { @@ -175,6 +215,8 @@ impl RuntimeIpcOperation { Self::CompactSession { request } => Some(&request.session_id), Self::UndoSession { request } => Some(&request.session_id), Self::RedoSession { request } => Some(&request.session_id), + Self::SessionUsage { request } => Some(&request.session_id), + Self::WaitForSettlement { request } => Some(&request.session_id), Self::SearchWorkspaceReferences { request } => Some(&request.session_id), Self::WorkspaceReferencesForMessage { request } => Some(&request.session_id), Self::GetSessionLineage { request } => Some(&request.anchor_session_id), @@ -187,6 +229,7 @@ impl RuntimeIpcOperation { Self::PendingPermissions { session_id } | Self::RespondPermission { session_id, .. } => Some(session_id), Self::SubmitUserAnswers { request } => Some(&request.session_id), + Self::RecordLocalCommandTurn { request } => Some(&request.session_id), Self::Health | Self::ListAgentModes { session_id: None } | Self::ListSessions { .. } @@ -237,9 +280,15 @@ impl RuntimeIpcOperation { | Self::SubmitUserAnswers { .. } => { RuntimeIpcOperationRules::new(CurrentController, false, false, true) } + Self::RecordLocalCommandTurn { .. } => { + RuntimeIpcOperationRules::new(CurrentController, true, false, true) + } Self::PendingPermissions { .. } => { RuntimeIpcOperationRules::new(CurrentController, false, false, false) } + Self::SessionUsage { .. } | Self::WaitForSettlement { .. } => { + RuntimeIpcOperationRules::new(CurrentController, false, false, false) + } Self::SearchWorkspaceReferences { .. } | Self::WorkspaceReferencesForMessage { .. } => { RuntimeIpcOperationRules::new(CurrentController, false, false, false) } @@ -309,6 +358,7 @@ pub enum RuntimeIpcOperationResult { }, SessionRestored { session: AgentSessionSummary, + state: RuntimeSessionState, workspace_binding: AgentSessionWorkspaceBinding, transcript: SessionTranscript, pending_permissions: Vec, @@ -321,6 +371,9 @@ pub enum RuntimeIpcOperationResult { SessionReverted { revert: AgentSessionRevertResult, }, + SessionUsage { + usage: SessionUsageReport, + }, SessionLineage { snapshot: Option, }, @@ -351,6 +404,9 @@ pub enum RuntimeIpcOperationResult { WorkspaceDiff { snapshot: WorkspaceDiffSnapshot, }, + LocalCommandTurnRecorded { + record: AgentLocalCommandTurnRecordResult, + }, } #[cfg(test)] diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs index 3788cde22a..628ad2f908 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 = 16; +pub const PROTOCOL_VERSION: u32 = 17; #[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/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index 295d8ae8ce..4fb106d322 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 @@ -75,7 +75,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, 16); + assert_eq!(PROTOCOL_VERSION, 17); let operation = RuntimeIpcOperation::ListAgentModes { session_id: Some("session-1".to_string()), }; @@ -130,7 +130,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, 16); + assert_eq!(PROTOCOL_VERSION, 17); let operation = RuntimeIpcOperation::SteerTurn { request: AgentDialogSteerRequest { session_id: "session-1".to_string(), @@ -246,7 +246,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, 16); + assert_eq!(PROTOCOL_VERSION, 17); let operation = RuntimeIpcOperation::WorkspaceDiff; let encoded = serde_json::to_value(&operation).expect("serialize workspace diff operation"); @@ -367,7 +367,7 @@ fn protocol_round_trips_the_reviewed_session_model_operation() { #[test] fn protocol_round_trips_the_current_session_rename_operation() { - assert_eq!(PROTOCOL_VERSION, 16); + assert_eq!(PROTOCOL_VERSION, 17); 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 11a780eedc..8f0e12e9a3 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 @@ -618,6 +618,7 @@ fn summary(session_id: &str) -> AgentSessionSummary { fn restored(session_id: &str) -> RuntimeIpcOperationResult { RuntimeIpcOperationResult::SessionRestored { session: summary(session_id), + state: crate::RuntimeSessionState::Idle, transcript: SessionTranscript { session_id: session_id.to_string(), messages: Vec::new(), diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index b37242b993..af3332354e 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -23,7 +23,7 @@ use bitfun_agent_runtime::sdk::{ }; use bitfun_harness::HarnessRegistry; use bitfun_runtime_ports::{ - AgentContextReloadRequest, ClockPort, LocalWorkspaceSnapshotPort, + AgentContextReloadPort, AgentContextReloadRequest, ClockPort, LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, LocalWorkspaceSnapshotTurnRequest, PortError, PortErrorKind, PortResult, RuntimeServiceCapability, RuntimeServicePort, SessionStoragePathRequest, SessionStorePort, @@ -1285,6 +1285,23 @@ impl CoreAgentRuntimeCompatibility { } } +#[async_trait::async_trait] +impl AgentContextReloadPort for CoreAgentRuntimeCompatibility { + async fn reload_session_context( + &self, + request: AgentContextReloadRequest, + ) -> 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(), + ) + }) + } +} + #[derive(Clone)] struct CoreSessionOperationsPort { coordinator: Arc, diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 617116872c..0784f9b6e5 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -13,7 +13,8 @@ use tokio_util::sync::CancellationToken; pub use bitfun_core_types::{ SessionExecutionTarget, SessionExecutionTargetKind, SessionExecutionTargetRequest, - WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSettings, WorktreeSummary, + SessionUsageReport, WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSettings, + WorktreeSummary, }; mod local_workspace_snapshot; @@ -1177,6 +1178,7 @@ pub struct AgentSessionDeleteRequest { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionRenameRequest { pub workspace_path: String, @@ -1204,6 +1206,7 @@ pub struct AgentSessionArchiveRequest { /// This is separate from [`AgentSessionArchiveRequest`] so existing archive-only /// consumers keep their current request shape and behavior. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionArchiveStateRequest { pub workspace_path: String, @@ -1265,6 +1268,7 @@ pub struct AgentUserShellCommandResult { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionModelUpdateRequest { pub session_id: String, @@ -1272,6 +1276,7 @@ pub struct AgentSessionModelUpdateRequest { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionModeUpdateRequest { pub session_id: String, @@ -1321,7 +1326,29 @@ pub struct AgentContextReloadRequest { pub target: AgentContextReloadTarget, } +#[async_trait::async_trait] +pub trait AgentContextReloadPort: Send + Sync { + async fn reload_session_context(&self, request: AgentContextReloadRequest) -> PortResult<()>; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Delivers answers to a pending user-question tool call. +pub struct AgentUserAnswersRequest { + pub tool_id: String, + pub answers: serde_json::Value, +} + +#[async_trait::async_trait] +/// Routes product responses to the existing user-input owner. +/// +/// Implementations do not own approval policy or interaction lifecycle state. +pub trait AgentInteractionResponsePort: Send + Sync { + async fn submit_user_answers(&self, request: AgentUserAnswersRequest) -> PortResult<()>; +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionForkRequest { pub workspace_path: String, @@ -1337,6 +1364,7 @@ pub struct AgentSessionForkRequest { /// This is additive to [`AgentSessionForkRequest`] so existing Rust SDK /// consumers keep the source-compatible latest-turn request shape. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionForkAtTurnRequest { pub workspace_path: String, @@ -1354,6 +1382,7 @@ pub struct AgentSessionForkAtTurnRequest { /// the fork. This stays separate from [`AgentSessionForkAtTurnRequest`] so its /// inclusive behavior remains source- and behavior-compatible. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionForkBeforeTurnRequest { pub workspace_path: String, @@ -1366,6 +1395,7 @@ pub struct AgentSessionForkBeforeTurnRequest { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionForkResult { pub session_id: String, diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index 65c36be6e4..be1ee9c8c3 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -11,8 +11,8 @@ use bitfun_agent_tools::{ToolRegistry, ToolRegistryItem}; use bitfun_harness::HarnessRegistry; use bitfun_runtime_ports::{ AgentBackgroundResultRequest, AgentDialogSteerRequest, AgentDialogTurnPort, - AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, - AgentLocalCommandTurnPort, AgentLocalCommandTurnRecordRequest, + AgentDialogTurnRequest, AgentInputAttachment, AgentInteractionResponsePort, + AgentLifecycleDeliveryPort, AgentLocalCommandTurnPort, AgentLocalCommandTurnRecordRequest, AgentLocalCommandTurnRecordResult, AgentMessageWorkspaceReferencesRequest, AgentSessionArchiveRequest, AgentSessionArchiveStateRequest, AgentSessionClosePort, AgentSessionCompactionPort, AgentSessionCompactionRequest, AgentSessionCompactionResult, @@ -31,12 +31,13 @@ use bitfun_runtime_ports::{ AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTransientSessionDiscardRequest, AgentTurnCancellationPort, AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentTurnSettlementPort, AgentTurnSettlementRequest, - AgentUserShellCommandPort, AgentUserShellCommandRequest, AgentUserShellCommandResult, - AgentWorkspaceReference, AgentWorkspaceReferencePort, AgentWorkspaceReferenceSearchRequest, - AgentWorkspaceReferenceSearchResult, DialogSteerOutcome, DialogSubmitOutcome, - PermissionAuditRecord, PermissionGrant, PermissionGrantKey, PluginRuntimeBinding, PortError, - PortErrorKind, PortResult, RuntimeEventEnvelope, SessionTranscript, SessionTranscriptReader, - SessionTranscriptRequest, ThreadGoal, WorkspaceDiffSnapshot, + AgentUserAnswersRequest, AgentUserShellCommandPort, AgentUserShellCommandRequest, + AgentUserShellCommandResult, AgentWorkspaceReference, AgentWorkspaceReferencePort, + AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, DialogSteerOutcome, + DialogSubmitOutcome, PermissionAuditRecord, PermissionGrant, PermissionGrantKey, + PluginRuntimeBinding, PortError, PortErrorKind, PortResult, RuntimeEventEnvelope, + SessionTranscript, SessionTranscriptReader, SessionTranscriptRequest, ThreadGoal, + WorkspaceDiffSnapshot, }; use bitfun_runtime_services::RuntimeServices; @@ -129,22 +130,6 @@ pub trait AgentSessionRestorePort: Send + Sync { ) -> PortResult; } -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -/// Delivers answers to a pending user-question tool call. -pub struct AgentUserAnswersRequest { - pub tool_id: String, - pub answers: serde_json::Value, -} - -#[async_trait::async_trait] -/// Routes product responses to the existing user-input owner. -/// -/// Implementations do not own approval policy or interaction lifecycle state. -pub trait AgentInteractionResponsePort: Send + Sync { - async fn submit_user_answers(&self, request: AgentUserAnswersRequest) -> PortResult<()>; -} - #[derive(Clone, Default)] pub struct AgentEventStream { events: Arc>>, diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index 71c821a356..705f63968f 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -45,10 +45,10 @@ pub use crate::post_call_hooks::{ RuntimeHookRegistryBuildError, }; pub use crate::runtime::{ - AgentEventStream, AgentInteractionResponsePort, AgentRunHandle, AgentRunRequest, - AgentSessionRestorePort, AgentSessionRestoreRequest, AgentSessionRestoreResult, - AgentUserAnswersRequest, RuntimeAgentRegistry, RuntimeAgentRegistryQuery, RuntimeBuildError, - RuntimeError, RuntimeToolRegistry, SessionSelector, + AgentEventStream, AgentRunHandle, AgentRunRequest, AgentSessionRestorePort, + AgentSessionRestoreRequest, AgentSessionRestoreResult, RuntimeAgentRegistry, + RuntimeAgentRegistryQuery, RuntimeBuildError, RuntimeError, RuntimeToolRegistry, + SessionSelector, }; pub use crate::session_state::{session_state_label_for_state, ProcessingPhase, SessionState}; pub use bitfun_agent_tools::{ToolRegistry, ToolRegistryItem}; @@ -63,16 +63,17 @@ pub use bitfun_harness::{ HarnessRegistry, HarnessWorkflow, }; pub use bitfun_runtime_ports::{ - AgentBackgroundResultRequest, AgentDialogSteerRequest, AgentDialogTurnExecution, - AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, - AgentLocalCommandTurnPort, AgentLocalCommandTurnRecordRequest, - AgentLocalCommandTurnRecordResult, AgentMessageWorkspaceReferencesRequest, - AgentSessionArchiveRequest, AgentSessionArchiveStateRequest, AgentSessionClosePort, - AgentSessionCompactionPort, AgentSessionCompactionRequest, AgentSessionCompactionResult, - AgentSessionComposerUpdate, AgentSessionCreateRequest, AgentSessionCreateResult, - AgentSessionDeleteRequest, AgentSessionForkAtTurnRequest, AgentSessionForkBeforeTurnRequest, - AgentSessionForkPort, AgentSessionForkRequest, AgentSessionForkResult, - AgentSessionLifecycleStatus, AgentSessionLineageCancellationRequest, AgentSessionLineageEntry, + AgentBackgroundResultRequest, AgentContextReloadPort, AgentDialogSteerRequest, + AgentDialogTurnExecution, AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, + AgentInteractionResponsePort, AgentLifecycleDeliveryPort, AgentLocalCommandTurnPort, + AgentLocalCommandTurnRecordRequest, AgentLocalCommandTurnRecordResult, + AgentMessageWorkspaceReferencesRequest, AgentSessionArchiveRequest, + AgentSessionArchiveStateRequest, AgentSessionClosePort, AgentSessionCompactionPort, + AgentSessionCompactionRequest, AgentSessionCompactionResult, AgentSessionComposerUpdate, + AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionDeleteRequest, + AgentSessionForkAtTurnRequest, AgentSessionForkBeforeTurnRequest, AgentSessionForkPort, + AgentSessionForkRequest, AgentSessionForkResult, AgentSessionLifecycleStatus, + AgentSessionLineageCancellationRequest, AgentSessionLineageEntry, AgentSessionLineageInspection, AgentSessionLineagePort, AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionManagementPort, AgentSessionModePort, AgentSessionModeUpdateRequest, @@ -85,16 +86,17 @@ pub use bitfun_runtime_ports::{ AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTransientSessionDiscardRequest, AgentTurnCancellationPort, AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentTurnSettlementPort, AgentTurnSettlementRequest, - AgentUserShellCommandPort, AgentUserShellCommandRequest, AgentUserShellCommandResult, - AgentWorkspaceReference, AgentWorkspaceReferenceKind, AgentWorkspaceReferencePort, - AgentWorkspaceReferenceSearchEntry, AgentWorkspaceReferenceSearchRequest, - AgentWorkspaceReferenceSearchResult, AgentWorkspaceReferenceSourceRange, ClockPort, - DialogSteerOutcome, DialogSubmissionPolicy, DialogSubmitOutcome, FileSystemPort, GitPort, - McpCatalogPort, NetworkPort, PermissionAuditRecord, PermissionDelegationContext, - PermissionGrant, PermissionGrantKey, PermissionReply, PermissionReplySource, PermissionRequest, - PermissionRequestEvent, PermissionRequestSource, PermissionRequestSourceKind, PortError, - PortErrorKind, PortResult, RemoteAssistantWorkspaceFacts, RemoteCapabilityPort, - RemoteConnectionPort, RemoteProjectionPort, RemoteRecentWorkspaceFacts, RemoteWorkspaceFacts, + AgentUserAnswersRequest, AgentUserShellCommandPort, AgentUserShellCommandRequest, + AgentUserShellCommandResult, AgentWorkspaceReference, AgentWorkspaceReferenceKind, + AgentWorkspaceReferencePort, AgentWorkspaceReferenceSearchEntry, + AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, + AgentWorkspaceReferenceSourceRange, ClockPort, DialogSteerOutcome, DialogSubmissionPolicy, + DialogSubmitOutcome, FileSystemPort, GitPort, McpCatalogPort, NetworkPort, + PermissionAuditRecord, PermissionDelegationContext, PermissionGrant, PermissionGrantKey, + PermissionReply, PermissionReplySource, PermissionRequest, PermissionRequestEvent, + PermissionRequestSource, PermissionRequestSourceKind, PortError, PortErrorKind, PortResult, + RemoteAssistantWorkspaceFacts, RemoteCapabilityPort, RemoteConnectionPort, + RemoteProjectionPort, RemoteRecentWorkspaceFacts, RemoteWorkspaceFacts, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, RemoteWorkspacePort, RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, RuntimeEventEnvelope, RuntimeEventSink, RuntimeEventType, RuntimeServiceCapability, RuntimeServicePort, SessionStorageKind, diff --git a/src/crates/interfaces/app-server-client/Cargo.toml b/src/crates/interfaces/app-server-client/Cargo.toml new file mode 100644 index 0000000000..0b4e6131dc --- /dev/null +++ b/src/crates/interfaces/app-server-client/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bitfun-app-server-client" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Transport-agnostic lightweight client for the BitFun App Server" + +[lib] +name = "bitfun_app_server_client" + +[dependencies] +agent-client-protocol = { workspace = true } +anyhow = { workspace = true } +bitfun-app-server-protocol = { path = "../app-server-protocol" } +tokio = { workspace = true, features = ["rt", "sync", "time"] } + +[lints] +workspace = true diff --git a/src/crates/interfaces/app-server-client/src/lib.rs b/src/crates/interfaces/app-server-client/src/lib.rs new file mode 100644 index 0000000000..e149094d39 --- /dev/null +++ b/src/crates/interfaces/app-server-client/src/lib.rs @@ -0,0 +1,448 @@ +//! Lightweight App Server client used by Rich Client surfaces. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use agent_client_protocol::{ConnectTo, ConnectionTo, JsonRpcResponse, SentRequest}; +use bitfun_app_server_protocol::app::{ + HealthRequest, HealthResponse, InitializeRequest, InitializeResponse, +}; +use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; +use bitfun_app_server_protocol::event::{ + AgentEventNotification, ConfigEventNotification, EventStreamStateNotification, + PermissionEventNotification, SyncEventsRequest, SyncEventsResponse, +}; +use bitfun_app_server_protocol::tui::*; +use bitfun_app_server_protocol::{AppClient, AppServer}; +use tokio::sync::{broadcast, oneshot}; + +const CLIENT_STARTUP_TIMEOUT: Duration = Duration::from_secs(5); +const SIDE_EFFECT_TIMEOUT: Duration = Duration::from_secs(120); + +#[derive(Debug, Clone)] +pub enum AppServerEvent { + Agent(AgentEventNotification), + Permission(PermissionEventNotification), + Config(ConfigEventNotification), + StreamState(EventStreamStateNotification), + ConnectionClosed, +} + +#[derive(Debug)] +pub enum ClientError { + Protocol(agent_client_protocol::Error), + Timeout(AppServerErrorData), +} + +impl std::fmt::Display for ClientError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Protocol(error) => write!(formatter, "{error}"), + Self::Timeout(data) => write!( + formatter, + "App Server request {} timed out with unknown outcome", + data.request_id.as_deref().unwrap_or("unknown") + ), + } + } +} + +impl std::error::Error for ClientError {} + +#[derive(Clone)] +pub struct AppServerClient { + connection: Arc>, + event_tx: broadcast::Sender, + shutdown_tx: Arc>>>, +} + +impl AppServerClient { + pub async fn initialize( + &self, + request: InitializeRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + + pub async fn health(&self) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(HealthRequest {}))).await + } + + pub fn subscribe_events(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } + + pub async fn sync_events( + &self, + request: SyncEventsRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + + pub async fn list_sessions( + &self, + request: ListSessionsRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + + pub async fn sync_session( + &self, + request: SyncSessionRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + + pub async fn read_transcript( + &self, + request: ReadTranscriptRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + + pub async fn resolve_workspace( + &self, + request: ResolveWorkspaceRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + + pub async fn create_session( + &self, + request: CreateSessionRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn delete_session( + &self, + request: DeleteSessionRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn rename_session( + &self, + request: RenameSessionRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn submit_dialog_turn( + &self, + request: SubmitDialogTurnRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn cancel_turn( + &self, + request: CancelTurnRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn steer_turn( + &self, + request: SteerTurnRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn run_user_shell_command( + &self, + request: RunUserShellCommandRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn submit_user_answers( + &self, + request: SubmitUserAnswersRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn record_local_command_turn( + &self, + request: RecordLocalCommandTurnRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn respond_permission( + &self, + request: RespondPermissionRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn pending_permissions( + &self, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(PendingPermissionsRequest {}))) + .await + } + + pub async fn compact_session( + &self, + request: CompactSessionRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn undo_session( + &self, + request: UndoSessionRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn redo_session( + &self, + request: RedoSessionRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn reload_context( + &self, + request: ReloadContextRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn session_usage( + &self, + request: SessionUsageRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + + pub async fn wait_for_settlement( + &self, + request: WaitForSettlementRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + + pub async fn workspace_diff(&self) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(WorkspaceDiffRequest {}))) + .await + } + + pub async fn search_workspace_references( + &self, + request: SearchWorkspaceReferencesRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + + pub async fn message_references( + &self, + request: MessageReferencesRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + + pub async fn session_lineage( + &self, + request: SessionLineageRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + + pub async fn inspect_lineage( + &self, + request: InspectLineageRequest, + ) -> agent_client_protocol::Result { + self.rpc(|cx| Ok(cx.send_request(request))).await + } + + pub async fn cancel_lineage( + &self, + request: CancelLineageRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn fork_session( + &self, + request: ForkSessionRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn fork_session_before_turn( + &self, + request: ForkSessionBeforeTurnRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn update_session_model( + &self, + request: UpdateSessionModelRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn update_session_mode( + &self, + request: UpdateSessionModeRequest, + ) -> Result { + self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) + .await + } + + pub async fn request_with_timeout( + &self, + send: impl FnOnce(&ConnectionTo) -> agent_client_protocol::Result>, + timeout: Duration, + ) -> Result { + let sent = send(&self.connection).map_err(ClientError::Protocol)?; + let request_id = sent.id().to_string(); + let (tx, rx) = oneshot::channel(); + sent.on_receiving_result(async move |result| { + tx.send(result) + .map_err(|_| agent_client_protocol::Error::internal_error()) + }) + .map_err(ClientError::Protocol)?; + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(result)) => result.map_err(ClientError::Protocol), + Ok(Err(_)) => Err(ClientError::Protocol( + agent_client_protocol::Error::internal_error(), + )), + Err(_) => Err(ClientError::Timeout(AppServerErrorData { + kind: AppServerErrorKind::OutcomeUnknown, + retryable: false, + outcome_unknown: true, + capability: None, + request_id: Some(request_id), + })), + } + } + + pub async fn shutdown(&self) { + if let Some(tx) = self + .shutdown_tx + .lock() + .ok() + .and_then(|mut guard| guard.take()) + { + let _ = tx.send(()); + } + } + + async fn rpc( + &self, + send: impl FnOnce(&ConnectionTo) -> agent_client_protocol::Result>, + ) -> agent_client_protocol::Result { + let sent = send(&self.connection)?; + let (tx, rx) = oneshot::channel(); + sent.on_receiving_result(async move |result| { + tx.send(result) + .map_err(|_| agent_client_protocol::Error::internal_error()) + })?; + rx.await + .map_err(|_| agent_client_protocol::Error::internal_error())? + } +} + +pub async fn connect( + transport: impl ConnectTo + 'static, +) -> Result { + let (event_tx, _) = broadcast::channel(1024); + let event_tx_for_task = event_tx.clone(); + let (cx_tx, cx_rx) = oneshot::channel::>(); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let connect_task = tokio::spawn(async move { + let result = AppClient + .builder() + .name("bitfun-rich-client") + .on_receive_notification( + { + let event_tx = event_tx_for_task.clone(); + async move |notification: AgentEventNotification, _cx| { + let _ = event_tx.send(AppServerEvent::Agent(notification)); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_notification( + { + let event_tx = event_tx_for_task.clone(); + async move |notification: PermissionEventNotification, _cx| { + let _ = event_tx.send(AppServerEvent::Permission(notification)); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_notification( + { + let event_tx = event_tx_for_task.clone(); + async move |notification: ConfigEventNotification, _cx| { + let _ = event_tx.send(AppServerEvent::Config(notification)); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_notification( + { + let event_tx = event_tx_for_task.clone(); + async move |notification: EventStreamStateNotification, _cx| { + let _ = event_tx.send(AppServerEvent::StreamState(notification)); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .connect_with(transport, async |cx: ConnectionTo| { + let _ = cx_tx.send(cx); + let _ = shutdown_rx.await; + Ok(()) + }) + .await; + let _ = event_tx_for_task.send(AppServerEvent::ConnectionClosed); + result + }); + + let connection = match tokio::time::timeout(CLIENT_STARTUP_TIMEOUT, cx_rx).await { + Ok(Ok(cx)) => cx, + Ok(Err(_)) => { + connect_task.abort(); + anyhow::bail!("App Server connection closed before startup completed"); + } + Err(_) => { + connect_task.abort(); + anyhow::bail!("App Server connection startup timed out"); + } + }; + + Ok(AppServerClient { + connection: Arc::new(connection), + event_tx, + shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), + }) +} diff --git a/src/crates/interfaces/app-server-protocol/Cargo.toml b/src/crates/interfaces/app-server-protocol/Cargo.toml new file mode 100644 index 0000000000..b20b881bb5 --- /dev/null +++ b/src/crates/interfaces/app-server-protocol/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "bitfun-app-server-protocol" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Behavior-light wire contracts and roles for the BitFun App Server" + +[lib] +name = "bitfun_app_server_protocol" + +[dependencies] +agent-client-protocol = { workspace = true } +bitfun-events = { path = "../../contracts/events" } +bitfun-core-types = { path = "../../contracts/core-types" } +bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false } +bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } +serde = { workspace = true } +serde_json = { workspace = true } +ts-rs = { workspace = true, optional = true } + +[features] +default = [] +ts = ["dep:ts-rs"] + +[lints] +workspace = true diff --git a/src/crates/interfaces/app-server-protocol/src/app.rs b/src/crates/interfaces/app-server-protocol/src/app.rs new file mode 100644 index 0000000000..a07dde4bdd --- /dev/null +++ b/src/crates/interfaces/app-server-protocol/src/app.rs @@ -0,0 +1,113 @@ +//! Connection initialization, capability, and health wire contracts. + +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use serde::{Deserialize, Serialize}; + +use crate::{MIN_PROTOCOL_VERSION, PROTOCOL_VERSION}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientInfo { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerInfo { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransportLimits { + pub max_frame_bytes: u64, + pub event_buffer_capacity: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "availability", rename_all = "camelCase")] +pub enum CapabilityAvailability { + Available, + Unavailable { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CapabilityDescriptor { + pub id: String, + pub availability: CapabilityAvailability, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub methods: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "app/initialize", response = InitializeResponse)] +#[serde(rename_all = "camelCase")] +pub struct InitializeRequest { + pub protocol_version: u32, + pub client: ClientInfo, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResponse { + pub protocol_version: u32, + pub minimum_protocol_version: u32, + pub server: ServerInfo, + pub capabilities: Vec, + pub limits: TransportLimits, +} + +impl InitializeResponse { + pub fn new( + server: ServerInfo, + capabilities: Vec, + limits: TransportLimits, + ) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + minimum_protocol_version: MIN_PROTOCOL_VERSION, + server, + capabilities, + limits, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "app/health", response = HealthResponse)] +pub struct HealthRequest {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct HealthResponse { + pub status: HealthStatus, + pub protocol_version: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum HealthStatus { + Ready, +} + +#[cfg(test)] +mod tests { + use super::{ClientInfo, InitializeRequest}; + use crate::method::INITIALIZE; + + #[test] + fn initialize_method_is_stable() { + assert_eq!(INITIALIZE, "app/initialize"); + let request = InitializeRequest { + protocol_version: 1, + client: ClientInfo { + name: "tui".to_string(), + version: "test".to_string(), + }, + }; + assert_eq!(request.protocol_version, 1); + } +} diff --git a/src/crates/interfaces/app-server-protocol/src/error.rs b/src/crates/interfaces/app-server-protocol/src/error.rs new file mode 100644 index 0000000000..656350c975 --- /dev/null +++ b/src/crates/interfaces/app-server-protocol/src/error.rs @@ -0,0 +1,72 @@ +//! Stable App Server error semantics. + +use serde::{Deserialize, Serialize}; + +pub const UNSUPPORTED_CODE: i64 = -32001; +pub const SESSION_IN_USE_CODE: i64 = -32002; +pub const STALE_REVISION_CODE: i64 = -32003; +pub const OUTCOME_UNKNOWN_CODE: i64 = -32004; +pub const STREAM_INVALIDATED_CODE: i64 = -32005; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppServerErrorKind { + Unsupported, + SessionInUse, + StaleRevision, + OutcomeUnknown, + StreamInvalidated, + InvalidRequest, + Internal, +} + +impl AppServerErrorKind { + pub const fn json_rpc_code(self) -> i64 { + match self { + Self::Unsupported => UNSUPPORTED_CODE, + Self::SessionInUse => SESSION_IN_USE_CODE, + Self::StaleRevision => STALE_REVISION_CODE, + Self::OutcomeUnknown => OUTCOME_UNKNOWN_CODE, + Self::StreamInvalidated => STREAM_INVALIDATED_CODE, + Self::InvalidRequest => -32602, + Self::Internal => -32603, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppServerErrorData { + pub kind: AppServerErrorKind, + #[serde(default)] + pub retryable: bool, + #[serde(default)] + pub outcome_unknown: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capability: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +#[cfg(test)] +mod tests { + use super::{AppServerErrorData, AppServerErrorKind, OUTCOME_UNKNOWN_CODE}; + + #[test] + fn outcome_unknown_has_a_stable_code_and_explicit_retry_semantics() { + assert_eq!( + AppServerErrorKind::OutcomeUnknown.json_rpc_code(), + OUTCOME_UNKNOWN_CODE + ); + let data = AppServerErrorData { + kind: AppServerErrorKind::OutcomeUnknown, + retryable: false, + outcome_unknown: true, + capability: None, + request_id: Some("request-1".to_string()), + }; + let value = serde_json::to_value(data).expect("serialize error data"); + assert_eq!(value["outcomeUnknown"], true); + assert_eq!(value["retryable"], false); + } +} diff --git a/src/crates/interfaces/app-server-protocol/src/event.rs b/src/crates/interfaces/app-server-protocol/src/event.rs new file mode 100644 index 0000000000..9b99f9c9f2 --- /dev/null +++ b/src/crates/interfaces/app-server-protocol/src/event.rs @@ -0,0 +1,134 @@ +//! Authoritative, sequenced App Server event contracts. + +use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}; +use bitfun_events::AgenticEventEnvelope; +use bitfun_product_domains::tool_permissions::{PermissionRequest, PermissionRequestEvent}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EventStream { + Agent, + Permission, + Config, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventCursor { + pub connection_id: String, + pub stream: EventStream, + pub sequence: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] +#[notification(method = "agent/event")] +#[serde(rename_all = "camelCase")] +pub struct AgentEventNotification { + pub cursor: EventCursor, + pub event: AgenticEventEnvelope, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] +#[notification(method = "agent/permissionEvent")] +#[serde(rename_all = "camelCase")] +pub struct PermissionEventNotification { + pub cursor: EventCursor, + pub event: PermissionRequestEvent, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] +#[notification(method = "config/event")] +#[serde(rename_all = "camelCase")] +pub struct ConfigEventNotification { + pub cursor: EventCursor, + pub event: ConfigUpdate, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] +#[notification(method = "app/eventStreamState")] +#[serde(rename_all = "camelCase")] +pub struct EventStreamStateNotification { + pub cursor: EventCursor, + pub stream: EventStream, + pub state: EventStreamState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub missed: Option, + pub resync: ResyncDirective, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EventStreamState { + Lagged, + Closed, + Invalidated, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResyncDirective { + pub method: String, + pub snapshot_available: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "app/syncEvents", response = SyncEventsResponse)] +#[serde(rename_all = "camelCase")] +pub struct SyncEventsRequest { + pub streams: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct SyncEventsResponse { + pub cursors: Vec, + pub pending_permissions: Vec, + pub agent_snapshot_available: bool, + pub config_snapshot_available: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum ConfigUpdate { + ModelConfigurationUpdated, + AiModelUpdated { + model_id: String, + model_name: String, + }, + DefaultAiModelUpdated { + model_id: String, + model_name: String, + }, + AppearanceUpdated { + appearance_id: String, + }, + EditorUpdated, + TerminalUpdated, + WorkspaceUpdated, + AppUpdated, + ConfigReloaded, + DebugModeConfigUpdated { + new_port: u16, + new_log_path: String, + }, + LogLevelUpdated { + new_level: String, + }, + LoggingSensitiveDiagnosticsUpdated { + include_sensitive_diagnostics: bool, + }, + ModelsReconciled { + invalidated_model_ids: Vec, + default_models_changed: bool, + func_agent_models_changed: bool, + agent_model_defaults_changed: bool, + }, +} diff --git a/src/crates/interfaces/app-server-protocol/src/lib.rs b/src/crates/interfaces/app-server-protocol/src/lib.rs new file mode 100644 index 0000000000..3d69ac987d --- /dev/null +++ b/src/crates/interfaces/app-server-protocol/src/lib.rs @@ -0,0 +1,21 @@ +//! Behavior-light wire contracts for BitFun App Server clients and hosts. +//! +//! This crate intentionally has no dependency on Core, Runtime implementations, +//! services, product assembly, or a UI framework. Server adapters translate +//! these wire DTOs to owner types at the interface boundary. + +pub mod app; +pub mod error; +pub mod event; +pub mod method; +pub mod role; +pub mod transport; +pub mod tui; + +pub use role::{AppClient, AppServer}; + +/// Current App Server protocol version. +pub const PROTOCOL_VERSION: u32 = 2; + +/// Oldest protocol version this implementation accepts. +pub const MIN_PROTOCOL_VERSION: u32 = 2; diff --git a/src/crates/interfaces/app-server-protocol/src/method.rs b/src/crates/interfaces/app-server-protocol/src/method.rs new file mode 100644 index 0000000000..06c066a068 --- /dev/null +++ b/src/crates/interfaces/app-server-protocol/src/method.rs @@ -0,0 +1,39 @@ +//! Stable method names and naming validation. + +pub const INITIALIZE: &str = "app/initialize"; +pub const HEALTH: &str = "app/health"; + +/// Validate the App Server `domain/lowerCamelCaseOperation` convention. +pub fn is_valid_method_name(method: &str) -> bool { + let Some((domain, operation)) = method.split_once('/') else { + return false; + }; + if domain.is_empty() || operation.is_empty() || operation.contains('/') { + return false; + } + is_lower_camel_identifier(domain) && is_lower_camel_identifier(operation) +} + +fn is_lower_camel_identifier(value: &str) -> bool { + value + .chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && value.chars().all(|ch| ch.is_ascii_alphanumeric()) +} + +#[cfg(test)] +mod tests { + use super::is_valid_method_name; + + #[test] + fn method_names_require_one_domain_separator_and_lower_camel_parts() { + assert!(is_valid_method_name("app/initialize")); + assert!(is_valid_method_name("session/forkBeforeTurn")); + assert!(!is_valid_method_name("initialize")); + assert!(!is_valid_method_name("App/initialize")); + assert!(!is_valid_method_name("app/Initialize")); + assert!(!is_valid_method_name("app/session/restore")); + assert!(!is_valid_method_name("app/restore_session")); + } +} diff --git a/src/crates/interfaces/app-server-protocol/src/role.rs b/src/crates/interfaces/app-server-protocol/src/role.rs new file mode 100644 index 0000000000..c468bbf09a --- /dev/null +++ b/src/crates/interfaces/app-server-protocol/src/role.rs @@ -0,0 +1,80 @@ +//! Generic JSON-RPC roles shared by App Server clients and hosts. + +use agent_client_protocol::role::{HasPeer, RemoteStyle}; +use agent_client_protocol::{Builder, ConnectionTo, Dispatch, Handled, Role, RoleId}; + +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AppServer; + +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AppClient; + +impl Role for AppServer { + type Counterpart = AppClient; + + async fn default_handle_dispatch_from( + &self, + message: Dispatch, + _connection: ConnectionTo, + ) -> Result, agent_client_protocol::Error> { + Ok(Handled::No { + message, + retry: false, + }) + } + + fn role_id(&self) -> RoleId { + RoleId::from_singleton(self) + } + + fn counterpart(&self) -> Self::Counterpart { + AppClient + } +} + +impl AppServer { + pub fn builder(self) -> Builder { + Builder::new(self) + } +} + +impl HasPeer for AppServer { + fn remote_style(&self, _peer: AppServer) -> RemoteStyle { + RemoteStyle::Counterpart + } +} + +impl Role for AppClient { + type Counterpart = AppServer; + + async fn default_handle_dispatch_from( + &self, + message: Dispatch, + _connection: ConnectionTo, + ) -> Result, agent_client_protocol::Error> { + Ok(Handled::No { + message, + retry: false, + }) + } + + fn role_id(&self) -> RoleId { + RoleId::from_singleton(self) + } + + fn counterpart(&self) -> Self::Counterpart { + AppServer + } +} + +impl AppClient { + pub fn builder(self) -> Builder { + Builder::new(self) + } +} + +impl HasPeer for AppClient { + fn remote_style(&self, _peer: AppClient) -> RemoteStyle { + RemoteStyle::Counterpart + } +} diff --git a/src/crates/interfaces/app-server-protocol/src/transport.rs b/src/crates/interfaces/app-server-protocol/src/transport.rs new file mode 100644 index 0000000000..b24b97f908 --- /dev/null +++ b/src/crates/interfaces/app-server-protocol/src/transport.rs @@ -0,0 +1,8 @@ +//! Transport helpers shared by App Server clients and hosts. + +use agent_client_protocol::Channel; + +/// Build a paired in-process server/client transport. +pub fn in_memory_channel_pair() -> (Channel, Channel) { + Channel::duplex() +} diff --git a/src/crates/interfaces/app-server-protocol/src/tui.rs b/src/crates/interfaces/app-server-protocol/src/tui.rs new file mode 100644 index 0000000000..c9f7439e62 --- /dev/null +++ b/src/crates/interfaces/app-server-protocol/src/tui.rs @@ -0,0 +1,321 @@ +//! Typed App Server requests used by the interactive TUI. +//! +//! The payloads reuse stable contract DTOs only. Runtime implementation types +//! are projected into the small wire-specific enums defined in this module by +//! the server adapter. + +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use bitfun_core_types::SessionUsageReport; +use bitfun_product_domains::tool_permissions::{PermissionReply, PermissionRequest}; +use bitfun_runtime_ports::{ + AgentContextReloadRequest, AgentDialogSteerRequest, AgentDialogTurnRequest, + AgentLocalCommandTurnRecordRequest, AgentLocalCommandTurnRecordResult, + AgentMessageWorkspaceReferencesRequest, AgentSessionCompactionRequest, + AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionDeleteRequest, + AgentSessionForkBeforeTurnRequest, AgentSessionForkRequest, AgentSessionForkResult, + AgentSessionLineageCancellationRequest, AgentSessionLineageInspection, + AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionLineageTranscriptRequest, + AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, + AgentSessionRenameRequest, AgentSessionRevertRequest, AgentSessionRevertResult, + AgentSessionSummary, AgentSessionUsageRequest, AgentSessionWorkspaceBinding, + AgentSessionWorkspaceRequest, AgentTurnCancellationRequest, AgentTurnCancellationResult, + AgentTurnSettlementRequest, AgentUserShellCommandRequest, AgentUserShellCommandResult, + AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, + AgentWorkspaceReferenceSearchResult, DialogSubmitOutcome, SessionTranscript, + SessionTranscriptRequest, WorkspaceDiffSnapshot, +}; +use serde::{Deserialize, Serialize}; + +macro_rules! unit_response { + ($name:ident) => { + #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] + pub struct $name {} + }; +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "agent/listSessions", response = ListSessionsResponse)] +pub struct ListSessionsRequest(pub AgentSessionListRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct ListSessionsResponse { + pub sessions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/sync", response = SyncSessionResponse)] +#[serde(rename_all = "camelCase")] +pub struct SyncSessionRequest { + pub workspace_path: String, + pub session_id: String, + #[serde(default)] + pub include_internal: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct SyncSessionResponse { + pub session: AgentSessionSummary, + pub state: SessionRuntimeState, + pub transcript: SessionTranscript, + pub workspace_binding: AgentSessionWorkspaceBinding, + #[serde(default)] + pub pending_permissions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum SessionRuntimeState { + Idle, + Processing { + current_turn_id: String, + phase: SessionProcessingPhase, + }, + Error { + error: String, + recoverable: bool, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SessionProcessingPhase { + Starting, + Compacting, + Thinking, + Streaming, + ToolCalling, + ToolConfirming, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/readTranscript", response = ReadTranscriptResponse)] +pub struct ReadTranscriptRequest(pub SessionTranscriptRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct ReadTranscriptResponse(pub SessionTranscript); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/resolveWorkspace", response = ResolveWorkspaceResponse)] +pub struct ResolveWorkspaceRequest(pub AgentSessionWorkspaceRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct ResolveWorkspaceResponse(pub Option); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "agent/steerTurn", response = SteerTurnResponse)] +pub struct SteerTurnRequest(pub AgentDialogSteerRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct SteerTurnResponse { + pub steering_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "agent/runUserShellCommand", response = RunUserShellCommandResponse)] +pub struct RunUserShellCommandRequest(pub AgentUserShellCommandRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct RunUserShellCommandResponse(pub AgentUserShellCommandResult); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "agent/submitUserAnswers", response = SubmitUserAnswersResponse)] +pub struct SubmitUserAnswersRequest { + pub tool_id: String, + pub answers: serde_json::Value, +} + +unit_response!(SubmitUserAnswersResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/recordLocalCommandTurn", response = RecordLocalCommandTurnResponse)] +pub struct RecordLocalCommandTurnRequest(pub AgentLocalCommandTurnRecordRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct RecordLocalCommandTurnResponse(pub AgentLocalCommandTurnRecordResult); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "agent/createSession", response = CreateSessionResponse)] +pub struct CreateSessionRequest(pub AgentSessionCreateRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct CreateSessionResponse(pub AgentSessionCreateResult); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "agent/deleteSession", response = DeleteSessionResponse)] +pub struct DeleteSessionRequest(pub AgentSessionDeleteRequest); + +unit_response!(DeleteSessionResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "agent/submitDialogTurn", response = SubmitDialogTurnResponse)] +pub struct SubmitDialogTurnRequest(pub AgentDialogTurnRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[serde(rename_all = "camelCase", tag = "status")] +pub enum SubmitDialogTurnResponse { + Started { session_id: String, turn_id: String }, + Queued { session_id: String, turn_id: String }, +} + +impl From for SubmitDialogTurnResponse { + fn from(outcome: DialogSubmitOutcome) -> Self { + match outcome { + DialogSubmitOutcome::Started { + session_id, + turn_id, + } => Self::Started { + session_id, + turn_id, + }, + DialogSubmitOutcome::Queued { + session_id, + turn_id, + } => Self::Queued { + session_id, + turn_id, + }, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/rename", response = RenameSessionResponse)] +pub struct RenameSessionRequest(pub AgentSessionRenameRequest); + +unit_response!(RenameSessionResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/compact", response = CompactSessionResponse)] +pub struct CompactSessionRequest(pub AgentSessionCompactionRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct CompactSessionResponse(pub bitfun_runtime_ports::AgentSessionCompactionResult); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/undo", response = RevertSessionResponse)] +pub struct UndoSessionRequest(pub AgentSessionRevertRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/redo", response = RevertSessionResponse)] +pub struct RedoSessionRequest(pub AgentSessionRevertRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct RevertSessionResponse(pub AgentSessionRevertResult); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/reloadContext", response = ReloadContextResponse)] +pub struct ReloadContextRequest(pub AgentContextReloadRequest); + +unit_response!(ReloadContextResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/usage", response = SessionUsageResponse)] +pub struct SessionUsageRequest(pub AgentSessionUsageRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct SessionUsageResponse(pub SessionUsageReport); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/waitForSettlement", response = WaitForSettlementResponse)] +pub struct WaitForSettlementRequest(pub AgentTurnSettlementRequest); + +unit_response!(WaitForSettlementResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "workspace/diff", response = WorkspaceDiffResponse)] +pub struct WorkspaceDiffRequest {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct WorkspaceDiffResponse(pub WorkspaceDiffSnapshot); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "workspace/searchReferences", response = SearchWorkspaceReferencesResponse)] +pub struct SearchWorkspaceReferencesRequest(pub AgentWorkspaceReferenceSearchRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct SearchWorkspaceReferencesResponse(pub AgentWorkspaceReferenceSearchResult); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "workspace/messageReferences", response = MessageReferencesResponse)] +pub struct MessageReferencesRequest(pub AgentMessageWorkspaceReferencesRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct MessageReferencesResponse(pub Vec); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/lineage", response = SessionLineageResponse)] +pub struct SessionLineageRequest(pub AgentSessionLineageRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct SessionLineageResponse(pub Option); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/inspectLineage", response = InspectLineageResponse)] +pub struct InspectLineageRequest(pub AgentSessionLineageTranscriptRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct InspectLineageResponse(pub AgentSessionLineageInspection); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/cancelLineage", response = CancelLineageResponse)] +pub struct CancelLineageRequest(pub AgentSessionLineageCancellationRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct CancelLineageResponse(pub AgentTurnCancellationResult); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/fork", response = ForkSessionResponse)] +pub struct ForkSessionRequest(pub AgentSessionForkRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/forkBeforeTurn", response = ForkSessionResponse)] +pub struct ForkSessionBeforeTurnRequest(pub AgentSessionForkBeforeTurnRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct ForkSessionResponse(pub AgentSessionForkResult); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/updateModel", response = UpdateSessionModelResponse)] +pub struct UpdateSessionModelRequest(pub AgentSessionModelUpdateRequest); + +unit_response!(UpdateSessionModelResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/updateMode", response = UpdateSessionModeResponse)] +pub struct UpdateSessionModeRequest(pub AgentSessionModeUpdateRequest); + +unit_response!(UpdateSessionModeResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "agent/respondPermission", response = RespondPermissionResponse)] +pub struct RespondPermissionRequest { + pub request_id: String, + pub reply: PermissionReply, +} + +unit_response!(RespondPermissionResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "agent/listPendingPermissionRequests", response = PendingPermissionsResponse)] +pub struct PendingPermissionsRequest {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct PendingPermissionsResponse { + pub requests: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "agent/cancelTurn", response = CancelTurnResponse)] +pub struct CancelTurnRequest(pub AgentTurnCancellationRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct CancelTurnResponse(pub AgentTurnCancellationResult); diff --git a/src/crates/interfaces/app-server/AGENTS-CN.md b/src/crates/interfaces/app-server/AGENTS-CN.md index 18ec43ecb0..5f3b412e41 100644 --- a/src/crates/interfaces/app-server/AGENTS-CN.md +++ b/src/crates/interfaces/app-server/AGENTS-CN.md @@ -1,73 +1,61 @@ **中文** | [English](AGENTS.md) -# App-Server 协议接口指南 +# App Server 服务端与接线指南 -适用范围:本指南适用于 `src/crates/interfaces/app-server`。 +适用范围:本指南只适用于 `src/crates/interfaces/app-server` 及其服务端生产接线。 -`bitfun-app-server` 负责基于 `agent_client_protocol` 自定义角色的协议无关 -JSON-RPC server/client 脚手架。role/transport 层不绑定 schema;使用者自行注册 -`JsonRpcRequest` / `JsonRpcNotification` 类型。可选的 `agent` / `schema` / -`server` 模块是 Phase 2 接线,通过 host 注入的 `AgentRuntime` 和通用 `AppServer` -角色暴露一组 agent kernel 操作(与 `bitfun-acp` 使用内置 ACP `Agent` 角色不同)。 +App Server 接口由四个 owner 分工: + +| Owner | 职责 | +|---|---| +| `app-server-protocol` | method、wire DTO、wire error、事件 envelope 和 schema-free protocol role | +| `app-server-client` | 类型化请求、类型化事件、连接行为和由 Host 提供的 transport 抽象 | +| `app-server` | server 生命周期、生产 handler 注册、事件转发、Runtime/domain 到 wire 的转换和错误映射 | +| `src/apps/*` 下的产品 Host | 具体 transport、认证、连接作用域、capability/limit 构造、平台能力、进程监督和关闭流程 | + +不要在本 crate 新增 protocol 或 client 所有权。消费者迁移期间可以保留 compatibility +module 和 re-export,但新 method、DTO、wire error 和类型化 client 行为必须放入相邻的 +protocol/client crate。 ## 护栏 -- role/transport/transport-helper 层保持 schema 无关。不要在 role/transport - helper 中硬编码领域方法或业务逻辑。Phase 2 的 `schema` 模块是 agent kernel - JSON-RPC 消息的唯一存放处,且只能映射到 `bitfun_agent_runtime` SDK 类型,不能 - 发明新的 kernel 行为。 -- `AppServer` / `AppClient` 是通用对等体;不要在此复用内置 ACP `Agent` / - `Client` 角色。`HasPeer` 按角色自身实现,因为 - `ConnectionTo::send_request` 要求 `Counterpart: HasPeer`。 -- `client` 模块(`AppServerClient`、`FrontendEvent`、`connect`)是 - **传输无关**的 app-server client:它驱动 host 提供的 transport 上的 - `AppClient`,并通过 broadcast channel 扇出投影后的 `agent/event` 通知。它是 - `BitfunAppServer::serve` 的对等体,后者同样接受 host 提供的 transport。Host 选择 - transport(内存 pair、stdio、websocket、...)并拥有 server 半连接;`connect` 只 - 拥有一个连接的 client 半连接。不要在此添加构造 server 的 `spawn` — server 构造是 - host 的职责。Host 特定的扇出、字段归一化和 JSON-RPC error-code 映射属于 host, - 不属于这里。添加新 host 的方式:依赖本 crate,在 transport 的 server 半连接上 - serve `BitfunAppServer`,并在 client 半连接上调用 - `bitfun_app_server::client::connect`。 -- Transport 构造器必须固定 `ByteStreams::new(outgoing, incoming)` 方向;不要暴露 - 易出错的 swap API。 -- 本 crate 在 option C 下拥有**完整后端契约**:app-server schema 是前端面对的单一 - JSON-RPC 接口,覆盖 agent kernel 操作(委托给 `bitfun-agent-runtime` SDK)和 - host 服务(git/mcp/config/cron/snapshot/fs/workspace/...)。为覆盖 host 服务,它 - 直接依赖 `assembly/core`(`bitfun-core`,`features = "product-full"`)— 与 - `bitfun-acp` 已有的模式相同(`bitfun-acp/Cargo.toml`)。Product assembly 构造 - `AgentRuntime` 和 host 服务单例并通过 `BitfunAppRuntime` 注入两者;host 服务的 - schema handler 调用 `bitfun_core::service::*`,与 Desktop host 相同(静态/全局 - 访问器),因此 `BitfunAppRuntime` 不需要按服务持有 host-services 字段。不要将本 - crate 描述为 host 服务操作的 Core 无关;agent-kernel handler 仍由 SDK facade 支持。 -- Handler 将 runtime 调用卸载到后台任务或立即返回;不要在 handler 回调内调用 - `SentRequest::block_task`(`jsonrpc.rs` 中的上游 `DEADLOCK` 注释)。通过 - `responder.respond_with_result` 回复。 +- compatibility role 和 transport helper 必须保持 schema-free。不要在 `AppServer` / + `AppClient`、流方向 helper 或 in-memory transport constructor 中硬编码领域 method 或业务行为。 +- `AppServer` / `AppClient` 是自定义协议对等角色;不要复用 ACP 内置的 `Agent` / + `Client` role,并保留协议要求的逐 role `HasPeer` 实现。 +- Transport constructor 必须固定 `ByteStreams::new(outgoing, incoming)` 的方向;不要暴露 + 容易交换方向的 API。具体 transport 由 Host 选择并持有。 +- 只注册由真实 Runtime、Service 或 Product Domain owner 支持的生产 handler。Handler 负责 + wire 合同校验和类型转换,不能持有第二份 Session、Permission、Config、capability 或生命周期状态。 +- 本 crate 只能选择已注册 handler 实际需要的最窄 `bitfun-core` owner feature;禁止使用 + `bitfun-core/product-full`。新增 owner feature 时必须同时增加对应的边界验证。 +- Host 特定的认证、身份、workspace/execution scope、capability availability、transport + limits、平台 provider、进程生命周期和连接 fan-out 留在 Host。不要从通用 server 默认值或 + 全局环境推断这些事实。 +- Handler 必须把 Runtime 调用卸载到异步任务或立即返回。不要在 handler callback 中调用 + `SentRequest::block_task`;通过 `responder.respond_with_result` 回复。 ## 事件投递 -Runtime 事件属于 app-server 协议接口,而非 host 侧订阅。流程在 transport 上是 -单向的: +Runtime 事件通过 App Server connection 交付,不属于 client 侧 Host 订阅: -- **Server** 持有注入的 `AgentEventSource`(由 host coordinator 发布的同一 - `EventQueue` 构建),其 `serve` main_fn 排空它,将每个 `AgenticEventEnvelope` - 作为 `agent/event` 通知(`SessionEventNotification`)通过 channel transport 转发 - 给 client。 -- **Client** 注册 `on_receive_notification(SessionEventNotification)` 接收它们,然后 - 投影并扇出给自己的消费者(websocket 连接、Tauri event bridge、...)。 -- Host 不得从 client 侧订阅 runtime `EventQueue`。Client 不触碰 - `AgentRuntime::subscribe_events` 或 `EventQueue`;这样做会绕过 app-server 接口并 - 破坏"所有 agent 接口经过 app-server"的契约。 +- Server 接收与同一 Runtime owner 关联的注入式 `AgentEventSource`,并通过 connection 转发 + 类型化 Agent、Permission、Config 和 stream-state notification。 +- 类型化 client crate 接收并扇出这些 notification。Host 不得让 App Server client 直接订阅 + Core `EventQueue`,否则会形成协议旁路。 +- connection-local sequence/cursor 和 sync 行为必须显式保留。在跨连接持久化 replay/resume + owner 与合同真正实现前,不得把当前能力描述为跨连接重放或恢复。 -## 验证(续) +## 错误映射 -在边界将 `RuntimeError` 映射为 JSON-RPC `Error`(见 -`BitfunAppRuntime::runtime_error` / `session_runtime_error`);不要通过 wire 泄露 -runtime 内部细节。 +在本 server adapter 中把 Runtime/domain failure 映射到 protocol-owned wire error。保持稳定 +kind 和结构化 data,不泄露 Runtime 内部细节。Host transport/auth/scope failure 仍由 Host +负责;owner failure 使用 `BitfunAppRuntime::runtime_error`、`session_runtime_error` 等 helper。 ## 验证 ```bash cargo check -p bitfun-app-server --offline cargo test -p bitfun-app-server --offline +pnpm run check:core-boundaries ``` diff --git a/src/crates/interfaces/app-server/AGENTS.md b/src/crates/interfaces/app-server/AGENTS.md index 2e852a693d..ef859abc20 100644 --- a/src/crates/interfaces/app-server/AGENTS.md +++ b/src/crates/interfaces/app-server/AGENTS.md @@ -1,83 +1,77 @@ [中文](AGENTS-CN.md) | **English** -# App-Server Protocol Surface Guide +# App Server Server and Wiring Guide -Scope: this guide applies to `src/crates/interfaces/app-server`. +Scope: this guide applies only to `src/crates/interfaces/app-server` and its +server-side production wiring. -`bitfun-app-server` owns a protocol-agnostic JSON-RPC server/client scaffold -built on `agent_client_protocol` custom roles. The role/transport layer is -schema-free; consumers register their own `JsonRpcRequest` / -`JsonRpcNotification` types. The optional `agent` / `schema` / `server` -modules are the Phase 2 wiring that exposes a ready set of agent kernel -operations over a host-injected `AgentRuntime` using the generic `AppServer` -role, unlike `bitfun-acp` which uses the built-in ACP `Agent` role. +The App Server surface is split across four owners: + +| Owner | Responsibility | +|---|---| +| `app-server-protocol` | Methods, wire DTOs, wire errors, event envelopes, and schema-free protocol roles | +| `app-server-client` | Typed requests, typed events, connection behavior, and a host-supplied transport abstraction | +| `app-server` | Server lifecycle, production handler registration, event forwarding, Runtime/domain-to-wire conversion, and error mapping | +| Product Host under `src/apps/*` | Concrete transport, authentication, connection scope, capability/limit construction, platform capabilities, process supervision, and shutdown | + +Do not add new protocol or client ownership to this crate. Compatibility +modules and re-exports may remain while consumers migrate, but new methods, +DTOs, wire errors, and typed client behavior belong in the adjacent protocol +and client crates. ## Guardrails -- Keep the role/transport/transport-helper layer schema-free. Do not hard-code - domain methods or business logic in the role/transport helpers. The Phase 2 - `schema` module is the one place agent kernel JSON-RPC messages live, and it - must only map to `bitfun_agent_runtime` SDK types, not invent new kernel - behavior. -- `AppServer` / `AppClient` are generic counterparts; do not reuse the built-in - `Agent` / `Client` ACP roles here. `HasPeer` is per-role on itself because - `ConnectionTo::send_request` requires `Counterpart: HasPeer`. -- The `client` module (`AppServerClient`, `FrontendEvent`, `connect`) is the - **transport-agnostic** app-server client: it drives an `AppClient` over a - host-supplied transport and fans projected `agent/event` notifications out - through a broadcast channel. It is the counterpart of `BitfunAppServer::serve`, - which likewise takes a host-supplied transport. Hosts pick the transport - (in-memory pair, stdio, websocket, ...) and own the server half; `connect` - only owns the client half of one connection. Do not add a server-constructing - `spawn` here -- server construction is a host concern. Host-specific fan-out, - field normalization, and JSON-RPC error-code mapping belong in the host, not - here. Add a new host by depending on this crate, serving `BitfunAppServer` on - the server half of a transport, and calling `bitfun_app_server::client::connect` - on the client half. -- Transport constructors must pin `ByteStreams::new(outgoing, incoming)` - direction; never expose a swap-prone API. -- This crate owns the **full backend contract** under option C: the app-server - schema is the single JSON-RPC surface the frontend faces, covering both agent - kernel operations (delegated to `bitfun-agent-runtime` SDK) and host services - (git/mcp/config/cron/snapshot/fs/workspace/...). To cover host services it - depends directly on `assembly/core` (`bitfun-core`, `features = "product-full"`) - -- the same pattern `bitfun-acp` already follows (`bitfun-acp/Cargo.toml`). - Product assembly constructs the `AgentRuntime` and the host service singletons - and injects both via `BitfunAppRuntime`; schema handlers for host services call - `bitfun_core::service::*` the same way the Desktop host does (static/global - accessors), so `BitfunAppRuntime` does not need a host-services field per - service. Do not describe this crate as Core-independent for host-service - operations; the agent-kernel handlers remain backed by the SDK facade only. -- Handlers offload runtime calls to background tasks or return immediately; - do not call `SentRequest::block_task` inside a handler callback (upstream - `DEADLOCK` note in `jsonrpc.rs`). Reply through `responder.respond_with_result`. -## Event delivery +- Keep compatibility role and transport helpers schema-free. Do not hard-code + domain methods or business behavior into `AppServer` / `AppClient`, stream + direction helpers, or in-memory transport constructors. +- `AppServer` / `AppClient` are custom protocol counterparts. Do not reuse the + built-in ACP `Agent` / `Client` roles. Preserve the required per-role + `HasPeer` implementation. +- Transport constructors must pin + `ByteStreams::new(outgoing, incoming)` direction; never expose a swap-prone + API. The Host chooses and owns the concrete transport. +- Register only production handlers backed by a real Runtime, Service, or + Product Domain owner. A handler validates the wire contract and converts + types; it must not hold a second copy of Session, Permission, Config, + capability, or lifecycle state. +- This crate may select only the narrow `bitfun-core` owner features required + by registered handlers. `bitfun-core/product-full` is forbidden. Add a new + owner feature only with the corresponding boundary verification. +- Host-specific authentication, identity, workspace/execution scope, + capability availability, transport limits, platform providers, process + lifecycle, and connection fan-out stay in the Host. Do not infer them from a + generic server default or global environment. +- Handlers must offload Runtime calls or return immediately. Do not call + `SentRequest::block_task` inside a handler callback; reply through + `responder.respond_with_result`. + +## Event Delivery -Runtime events are part of the app-server protocol surface, not a host-side -subscription. The flow is one-directional over the transport: +Runtime events cross the App Server connection; they are not a client-side +Host subscription: -- The **server** holds an injected `AgentEventSource` (built from the same - `EventQueue` the host coordinator publishes to) and its `serve` main_fn - drains it, forwarding each `AgenticEventEnvelope` to the client as an - `agent/event` notification (`SessionEventNotification`) over the channel - transport. -- The **client** registers `on_receive_notification(SessionEventNotification)` - to receive them, then projects and fans them out to its own consumers - (websocket connections, Tauri event bridge, ...). -- Hosts must NOT subscribe to the runtime `EventQueue` from the client side. - The client never touches `AgentRuntime::subscribe_events` or the - `EventQueue` directly; doing so bypasses the app-server surface and breaks - the "all agent interfaces go through app-server" contract. +- The server receives an injected `AgentEventSource` associated with the same + Runtime owner and forwards typed Agent, Permission, Config, and stream-state + notifications through the connection. +- The typed client crate receives and fans out those notifications. A Host + must not make its App Server client subscribe directly to the Core + `EventQueue`, because that creates a protocol bypass. +- 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. -## Verification (continued) +## Error Mapping -Map `RuntimeError` to JSON-RPC `Error` at the boundary (see -`BitfunAppRuntime::runtime_error` / `session_runtime_error`); do not leak -runtime internals through the wire. +Map Runtime and domain failures to protocol-owned wire errors in this server +adapter. Keep stable kinds and structured data, and do not leak Runtime +internals. Host transport/auth/scope failures remain Host-owned; owner +failures use helpers such as `BitfunAppRuntime::runtime_error` and +`session_runtime_error`. ## Verification ```bash cargo check -p bitfun-app-server --offline cargo test -p bitfun-app-server --offline +pnpm run check:core-boundaries ``` diff --git a/src/crates/interfaces/app-server/Cargo.toml b/src/crates/interfaces/app-server/Cargo.toml index ee7594c4d6..4a3abefb41 100644 --- a/src/crates/interfaces/app-server/Cargo.toml +++ b/src/crates/interfaces/app-server/Cargo.toml @@ -10,16 +10,14 @@ name = "bitfun_app_server" [dependencies] agent-client-protocol = { workspace = true } -# Host services (git/mcp/config/cron/snapshot/fs/workspace/...) live in -# `bitfun-core`. The app-server surface owns the full backend contract under -# option C, so it depends on core directly -- mirroring `bitfun-acp`, which -# already does the same (`bitfun-acp/Cargo.toml`). Product assembly constructs -# the `AgentRuntime` and the host service singletons and injects both via -# `BitfunAppRuntime`; the schema handlers call `bitfun_core::service::*` the -# same way the Desktop host does (static/global accessors, no extra injection). -bitfun-core = { path = "../../assembly/core", default-features = false, features = ["product-full"] } +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", default-features = false, features = ["external-sources"] } bitfun-agent-runtime = { path = "../../execution/agent-runtime" } bitfun-events = { path = "../../contracts/events" } +bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } tokio-util = { workspace = true, features = ["compat"] } futures = { workspace = true } @@ -33,11 +31,19 @@ ts-rs = { workspace = true, optional = true } [features] default = [] # Schema-first TypeScript binding export. Derives `ts_rs::TS` (via -# `cfg_attr`) on the wire types in `schema.rs` and propagates the `ts` feature +# `cfg_attr`) on the wire types under `src/schema/` and propagates the `ts` feature # to the upstream contract/service crates so every type the schema references # also implements `TS`. Run `cargo test --features ts export_types` (or the # frontend `gen:types` script) to emit `.ts` into `TS_RS_EXPORT_DIR`. -ts = ["dep:ts-rs", "bitfun-core/ts"] +ts = [ + "dep:ts-rs", + "bitfun-app-server-protocol/ts", + "bitfun-core/ts", + "bitfun-runtime-ports/ts", +] [lints] workspace = true + +[dev-dependencies] +bitfun-app-server-client = { path = "../app-server-client" } diff --git a/src/crates/interfaces/app-server/src/agent.rs b/src/crates/interfaces/app-server/src/agent.rs index 6ba1272262..519918a6f9 100644 --- a/src/crates/interfaces/app-server/src/agent.rs +++ b/src/crates/interfaces/app-server/src/agent.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use agent_client_protocol::{Error, Result}; use bitfun_agent_runtime::sdk::{AgentEventSource, AgentRuntime, PortErrorKind, RuntimeError}; use bitfun_core::service::git::GitError; +use bitfun_runtime_ports::AgentContextReloadPort; /// Host-injected BitFun agent runtime exposed over the app-server surface. /// @@ -23,6 +24,7 @@ use bitfun_core::service::git::GitError; pub struct BitfunAppRuntime { runtime: Arc, event_source: AgentEventSource, + context_reload: Option>, } impl std::fmt::Debug for BitfunAppRuntime { @@ -41,9 +43,15 @@ 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 { @@ -59,6 +67,10 @@ 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 @@ -177,7 +189,10 @@ mod tests { ); // The structured enum still rides along in `data` for callers that // inspect it. - assert!(mapped.data.is_some(), "data must carry the BitFunError enum"); + assert!( + mapped.data.is_some(), + "data must carry the BitFunError enum" + ); } /// Non-`NotFound` config errors must fall back to the generic `bitfun_error` @@ -190,8 +205,7 @@ mod tests { let mapped = config_get_error(error); assert_eq!( - mapped.message, - "Internal error", + mapped.message, "Internal error", "non-NotFound errors must keep the generic message, got: {mapped:?}" ); assert!(mapped.data.is_some()); diff --git a/src/crates/interfaces/app-server/src/client.rs b/src/crates/interfaces/app-server/src/client.rs index 99ee3380c7..d4a64c724e 100644 --- a/src/crates/interfaces/app-server/src/client.rs +++ b/src/crates/interfaces/app-server/src/client.rs @@ -42,32 +42,37 @@ use std::sync::Arc; use std::time::Duration; -use agent_client_protocol::{ConnectionTo, JsonRpcResponse, Result, SentRequest}; use agent_client_protocol::ConnectTo; +use agent_client_protocol::{ConnectionTo, JsonRpcResponse, Result, SentRequest}; use bitfun_events::project_agentic_frontend_event; use tokio::sync::{broadcast, oneshot}; use crate::schema::{ CancelTurnMessage, CancelTurnResponse, ClearProjectPermissionGrantsMessage, - ClearProjectPermissionGrantsResponse, CreateSessionMessage, CreateSessionResponse, - DeleteSessionMessage, DeleteSessionResponse, GetAgentProfileConfigMessage, - GetAgentProfileConfigResponse, GetAgentProfileConfigsMessage, - GetAgentProfileConfigsResponse, GetConfigMessage, GetConfigResponse, GetConfigsMessage, - GetConfigsResponse, GetModelConfigsMessage, GetModelConfigsResponse, GitBranchesRequest, - GitGetBranchesMessage, GitGetBranchesResponse, GitGetStatusMessage, GitGetStatusResponse, - GitIsRepositoryMessage, GitIsRepositoryResponse, GitRepositoryPathRequest, - I18nGetCurrentLanguageMessage, I18nGetCurrentLanguageResponse, I18nGetConfigMessage, - I18nGetSupportedLanguagesMessage, I18nGetSupportedLanguagesResponse, I18nSetConfigMessage, - I18nSetConfigResponse, I18nSetLanguageMessage, I18nSetLanguageResponse, - ListPendingPermissionRequestsMessage, ListPendingPermissionRequestsResponse, - ListProjectPermissionAuditMessage, ListProjectPermissionAuditResponse, - ListProjectPermissionGrantsMessage, ListProjectPermissionGrantsResponse, - ListSessionsMessage, ListSessionsResponse, PermissionEventNotification, - RemoveProjectPermissionGrantMessage, RemoveProjectPermissionGrantResponse, - RespondPermissionBatchMessage, RespondPermissionBatchResponse, RespondPermissionMessage, - RespondPermissionResponse, RunMessage, RunResponse, SessionEventNotification, - SetConfigMessage, SetConfigResponse, SubmitDialogTurnMessage, SubmitTurnMessage, - SubmitTurnResponse, + ClearProjectPermissionGrantsResponse, ConfigEventNotification, ConfigUpdate, + CreateSessionMessage, CreateSessionResponse, DeleteSessionMessage, DeleteSessionResponse, + ForkSessionAtTurnMessage, ForkSessionBeforeTurnMessage, ForkSessionMessage, + ForkSessionResponse, GetAgentProfileConfigMessage, GetAgentProfileConfigResponse, + GetAgentProfileConfigsMessage, GetAgentProfileConfigsResponse, GetConfigMessage, + GetConfigResponse, GetConfigsMessage, GetConfigsResponse, GetModelConfigsMessage, + GetModelConfigsResponse, GitBranchesRequest, GitGetBranchesMessage, GitGetBranchesResponse, + GitGetStatusMessage, GitGetStatusResponse, GitIsRepositoryMessage, GitIsRepositoryResponse, + GitRepositoryPathRequest, I18nGetConfigMessage, I18nGetCurrentLanguageMessage, + I18nGetCurrentLanguageResponse, I18nGetSupportedLanguagesMessage, + I18nGetSupportedLanguagesResponse, I18nSetConfigMessage, I18nSetConfigResponse, + I18nSetLanguageMessage, I18nSetLanguageResponse, ListPendingPermissionRequestsMessage, + ListPendingPermissionRequestsResponse, ListProjectPermissionAuditMessage, + ListProjectPermissionAuditResponse, ListProjectPermissionGrantsMessage, + ListProjectPermissionGrantsResponse, ListSessionsMessage, ListSessionsResponse, + PermissionEventNotification, RemoveProjectPermissionGrantMessage, + RemoveProjectPermissionGrantResponse, RenameSessionMessage, RenameSessionResponse, + ResetAgentProfileConfigMessage, ResetAgentProfileConfigResponse, RespondPermissionBatchMessage, + RespondPermissionBatchResponse, RespondPermissionMessage, RespondPermissionResponse, + RestoreSessionMessage, RunMessage, RunResponse, SessionEventNotification, + SetAgentProfileConfigMessage, SetAgentProfileConfigResponse, SetConfigMessage, + SetConfigResponse, SetSessionArchivedMessage, SetSessionArchivedResponse, + SubmitDialogTurnMessage, SubmitTurnMessage, SubmitTurnResponse, UpdateSessionModeMessage, + UpdateSessionModeResponse, UpdateSessionModelMessage, UpdateSessionModelResponse, }; use crate::{AppClient, AppServer}; @@ -103,6 +108,7 @@ const CLIENT_STARTUP_TIMEOUT: Duration = Duration::from_secs(5); pub struct AppServerClient { connection: Arc>, event_tx: broadcast::Sender, + config_event_tx: broadcast::Sender, shutdown_tx: Arc>>>, } @@ -114,6 +120,11 @@ impl AppServerClient { self.event_tx.subscribe() } + /// Subscribe to canonical configuration updates from `config/event`. + pub fn subscribe_config_updates(&self) -> broadcast::Receiver { + self.config_event_tx.subscribe() + } + /// Shut down the in-process app-server client connection. Signals the /// parked `connect_task` to resume, which lets the connection's background /// actors (task/outgoing/incoming/responder) unwind and close the @@ -121,7 +132,12 @@ impl AppServerClient { /// Hosts that want the connection to live for the process lifetime simply /// never call this. pub async fn shutdown(&self) { - if let Some(tx) = self.shutdown_tx.lock().ok().and_then(|mut guard| guard.take()) { + if let Some(tx) = self + .shutdown_tx + .lock() + .ok() + .and_then(|mut guard| guard.take()) + { let _ = tx.send(()); } } @@ -159,6 +175,102 @@ impl AppServerClient { Ok(()) } + /// Rename a persisted Session through the Runtime Session owner. + pub async fn rename_session( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionRenameRequest, + ) -> Result<()> { + let RenameSessionResponse {} = self + .rpc(|cx| cx.send_request(RenameSessionMessage(request))) + .await?; + Ok(()) + } + + /// Set the persisted archive state through the Runtime Session owner. + pub async fn set_session_archived( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionArchiveStateRequest, + ) -> Result<()> { + let SetSessionArchivedResponse {} = self + .rpc(|cx| cx.send_request(SetSessionArchivedMessage(request))) + .await?; + Ok(()) + } + + /// Update the selected model for an already loaded Session. + pub async fn update_session_model( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionModelUpdateRequest, + ) -> Result<()> { + let UpdateSessionModelResponse {} = self + .rpc(|cx| cx.send_request(UpdateSessionModelMessage(request))) + .await?; + Ok(()) + } + + /// Update the selected mode for an already loaded Session. + pub async fn update_session_mode( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionModeUpdateRequest, + ) -> Result<()> { + let UpdateSessionModeResponse {} = self + .rpc(|cx| cx.send_request(UpdateSessionModeMessage(request))) + .await?; + Ok(()) + } + + /// Fork a Session at its latest persisted turn. + pub async fn fork_session( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionForkRequest, + ) -> Result { + let ForkSessionResponse(result) = self + .rpc(|cx| cx.send_request(ForkSessionMessage(request))) + .await?; + Ok(result) + } + + /// Fork a Session including the selected persisted turn. + pub async fn fork_session_at_turn( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionForkAtTurnRequest, + ) -> Result { + let ForkSessionResponse(result) = self + .rpc(|cx| cx.send_request(ForkSessionAtTurnMessage(request))) + .await?; + Ok(result) + } + + /// Fork a Session immediately before the selected persisted turn. + pub async fn fork_session_before_turn( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionForkBeforeTurnRequest, + ) -> Result { + let ForkSessionResponse(result) = self + .rpc(|cx| cx.send_request(ForkSessionBeforeTurnMessage(request))) + .await?; + Ok(result) + } + + /// Restore a persisted Session into the Runtime owner. + pub async fn restore_session( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionRestoreRequest, + ) -> Result { + let response = self + .rpc(|cx| { + cx.send_request(RestoreSessionMessage { + workspace_path: request.workspace_path, + session_id: request.session_id, + include_internal: request.include_internal, + remote_connection_id: request.remote_connection_id, + remote_ssh_host: request.remote_ssh_host, + }) + }) + .await?; + Ok(response.into()) + } + /// Submit a turn via `agent/submitTurn`. pub async fn submit_turn( &self, @@ -270,10 +382,7 @@ impl AppServerClient { /// Clear all permission grants for a project via /// `agent/clearProjectPermissionGrants`. Returns the count cleared. - pub async fn clear_project_permission_grants( - &self, - project_id: &str, - ) -> Result { + pub async fn clear_project_permission_grants(&self, project_id: &str) -> Result { let ClearProjectPermissionGrantsResponse { cleared } = self .rpc(|cx| { cx.send_request(ClearProjectPermissionGrantsMessage { @@ -435,6 +544,38 @@ impl AppServerClient { Ok(()) } + /// Canonicalize and persist one agent profile, returning the effective view. + pub async fn set_agent_profile_config( + &self, + agent_id: &str, + config: serde_json::Value, + ) -> Result { + let SetAgentProfileConfigResponse(view) = self + .rpc(|cx| { + cx.send_request(SetAgentProfileConfigMessage { + agent_id: agent_id.to_string(), + config, + }) + }) + .await?; + Ok(view) + } + + /// Reset one agent profile and return the effective default view. + pub async fn reset_agent_profile_config( + &self, + agent_id: &str, + ) -> Result { + let ResetAgentProfileConfigResponse(view) = self + .rpc(|cx| { + cx.send_request(ResetAgentProfileConfigMessage { + agent_id: agent_id.to_string(), + }) + }) + .await?; + Ok(view) + } + /// Read the current runtime locale id via `i18n/getCurrentLanguage`. pub async fn i18n_get_current_language(&self) -> Result { let I18nGetCurrentLanguageResponse { language } = self @@ -458,9 +599,7 @@ impl AppServerClient { /// Read the i18n config (current/fallback language, autoDetect) via /// `i18n/getConfig`. - pub async fn i18n_get_config( - &self, - ) -> Result { + pub async fn i18n_get_config(&self) -> Result { let response = self .rpc(|cx| cx.send_request(I18nGetConfigMessage {})) .await?; @@ -542,6 +681,8 @@ pub async fn connect( // broadcasts it to consumers. let (event_tx, _) = broadcast::channel::(1024); let event_tx_for_task = event_tx.clone(); + let (config_event_tx, _) = broadcast::channel::(256); + let config_event_tx_for_task = config_event_tx.clone(); // Park the connection handle through a oneshot, then await a never-sent // shutdown signal so `connect_with`'s main_fn keeps the connection alive. @@ -556,7 +697,9 @@ pub async fn connect( let event_tx = event_tx_for_task.clone(); async move |notification: SessionEventNotification, _cx: ConnectionTo| { - let SessionEventNotification(envelope) = notification; + let SessionEventNotification { + event: envelope, .. + } = notification; if let Some(projected) = project_agentic_frontend_event(envelope.event) { let _ = event_tx.send(FrontendEvent { event: projected.event_name, @@ -568,12 +711,20 @@ pub async fn connect( }, agent_client_protocol::on_receive_notification!(), ) + .on_receive_notification( + async move |notification: ConfigEventNotification, _cx: ConnectionTo| { + let ConfigEventNotification { event, .. } = notification; + let _ = config_event_tx_for_task.send(event); + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) .on_receive_notification( { let event_tx = event_tx_for_task; async move |notification: PermissionEventNotification, _cx: ConnectionTo| { - let PermissionEventNotification(event) = notification; + let PermissionEventNotification { event, .. } = notification; // Project the permission lifecycle event to the frontend // `permission://event` channel the desktop host uses, so // consumers can listen on the same name in web and desktop. @@ -624,6 +775,7 @@ pub async fn connect( Ok(AppServerClient { connection: Arc::new(connection), event_tx, + config_event_tx, shutdown_tx: Arc::new(std::sync::Mutex::new(Some(shutdown_tx))), }) } diff --git a/src/crates/interfaces/app-server/src/role.rs b/src/crates/interfaces/app-server/src/role.rs index acf80cf6f4..205e4042c7 100644 --- a/src/crates/interfaces/app-server/src/role.rs +++ b/src/crates/interfaces/app-server/src/role.rs @@ -1,106 +1,3 @@ -//! Generic JSON-RPC app-server roles built on `agent_client_protocol`. -//! -//! These are protocol-agnostic counterparts of the built-in ACP -//! [`Agent`](agent_client_protocol::Agent)/[`Client`](agent_client_protocol::Client) -//! pair: [`AppServer`] receives requests and sends responses/notifications, -//! [`AppClient`] sends requests and receives responses/notifications. They do -//! not bind any ACP schema; consumers register their own `JsonRpcRequest` / -//! `JsonRpcNotification` types via [`Builder::on_receive_request`] etc. -//! -//! `HasPeer` is implemented per-role on itself because -//! [`ConnectionTo::send_request`] requires `Counterpart: HasPeer`, -//! matching how the built-in `Client`/`Agent` roles are wired -//! (`impl HasPeer for Client`, not for `Agent`). +//! Compatibility re-export of the behavior-light protocol roles. -use agent_client_protocol::role::{HasPeer, RemoteStyle}; -use agent_client_protocol::{Builder, ConnectionTo, Dispatch, Handled, Role, RoleId}; - -/// The server role of a generic JSON-RPC app-server connection. -/// -/// Use `AppServer.builder()` and register request/notification handlers, then -/// `connect_to(transport)` to serve. Handlers receive a -/// [`ConnectionTo`] for sending notifications back to the client. -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct AppServer; - -/// The client role of a generic JSON-RPC app-server connection. -/// -/// Use `AppClient.builder()` and `connect_with(transport, main_fn)` to drive -/// the connection; `main_fn` receives a [`ConnectionTo`] for -/// sending requests/notifications and awaiting responses. -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct AppClient; - -impl Role for AppServer { - type Counterpart = AppClient; - - async fn default_handle_dispatch_from( - &self, - message: Dispatch, - _connection: ConnectionTo, - ) -> Result, agent_client_protocol::Error> { - // No default handler: unmatched messages fall through to the caller's - // `on_receive_dispatch` handler (or are dropped if none is registered). - Ok(Handled::No { - message, - retry: false, - }) - } - - fn role_id(&self) -> RoleId { - RoleId::from_singleton(self) - } - - fn counterpart(&self) -> Self::Counterpart { - AppClient - } -} - -impl AppServer { - /// Create a connection builder playing the server role. - pub fn builder(self) -> Builder { - Builder::new(self) - } -} - -impl HasPeer for AppServer { - fn remote_style(&self, _peer: AppServer) -> RemoteStyle { - RemoteStyle::Counterpart - } -} - -impl Role for AppClient { - type Counterpart = AppServer; - - async fn default_handle_dispatch_from( - &self, - message: Dispatch, - _connection: ConnectionTo, - ) -> Result, agent_client_protocol::Error> { - Ok(Handled::No { - message, - retry: false, - }) - } - - fn role_id(&self) -> RoleId { - RoleId::from_singleton(self) - } - - fn counterpart(&self) -> Self::Counterpart { - AppServer - } -} - -impl AppClient { - /// Create a connection builder playing the client role. - pub fn builder(self) -> Builder { - Builder::new(self) - } -} - -impl HasPeer for AppClient { - fn remote_style(&self, _peer: AppClient) -> RemoteStyle { - RemoteStyle::Counterpart - } -} +pub use bitfun_app_server_protocol::role::{AppClient, AppServer}; diff --git a/src/crates/interfaces/app-server/src/schema.rs b/src/crates/interfaces/app-server/src/schema.rs deleted file mode 100644 index c6b8418b69..0000000000 --- a/src/crates/interfaces/app-server/src/schema.rs +++ /dev/null @@ -1,767 +0,0 @@ -//! JSON-RPC schema for the BitFun agent kernel app-server surface. -//! -//! These messages are the wire contract between an `AppClient` and the -//! [`crate::BitfunAppServer`]. The runtime port types (in -//! `bitfun_runtime_ports`) already derive `Serialize`/`Deserialize`, but they do -//! not implement `agent_client_protocol::JsonRpcResponse`, so each response is -//! wrapped in a newtype that derives `JsonRpcResponse`. The `run` operation -//! additionally maps the non-serde `SessionSelector` / `AgentRunHandle` to -//! wire-friendly types. - -use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}; -use bitfun_agent_runtime::sdk::{ - AgentDialogTurnExecution, AgentDialogTurnRequest, AgentInputAttachment, AgentRunHandle, - AgentRunRequest, AgentSessionCreateRequest, AgentSessionCreateResult, - AgentSessionDeleteRequest, AgentSessionListRequest, AgentSessionSummary, - AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, - AgentTurnCancellationRequest, AgentTurnCancellationResult, AgenticEventEnvelope, - DialogSubmissionPolicy, DialogSubmitOutcome, PermissionAuditRecord, PermissionGrant, - PermissionGrantKey, PermissionReply, PermissionRequest, PermissionRequestEvent, - SessionSelector, -}; -use serde::{Deserialize, Serialize}; - -/// `agent/createSession` request body (wraps the port request type). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[request(method = "agent/createSession", response = CreateSessionResponse)] -pub struct CreateSessionMessage(pub AgentSessionCreateRequest); - -/// `agent/createSession` response body (wraps the port result type). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -pub struct CreateSessionResponse(pub AgentSessionCreateResult); - -/// `agent/listSessions` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[request(method = "agent/listSessions", response = ListSessionsResponse)] -pub struct ListSessionsMessage(pub AgentSessionListRequest); - -/// `agent/listSessions` response body. The summary vector is wrapped because -/// `JsonRpcRequest::Response` must be a single named type, not a bare `Vec`. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct ListSessionsResponse { - pub sessions: Vec, -} - -/// `agent/deleteSession` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[request(method = "agent/deleteSession", response = DeleteSessionResponse)] -pub struct DeleteSessionMessage(pub AgentSessionDeleteRequest); - -/// `agent/deleteSession` response body. The runtime returns `()` so this is a -/// structurally empty success marker. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct DeleteSessionResponse {} - -/// `agent/submitTurn` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[request(method = "agent/submitTurn", response = SubmitTurnResponse)] -pub struct SubmitTurnMessage(pub AgentSubmissionRequest); - -/// `agent/submitTurn` response body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -pub struct SubmitTurnResponse(pub AgentSubmissionResult); - -/// `agent/submitDialogTurn` request body. -/// -/// This is the dialog-turn operation the desktop `start_dialog_turn` command -/// drives (via the SDK's [`AgentRuntime::submit_dialog_turn`]), unlike -/// `agent/submitTurn` which is a bare message into an existing session. The -/// body mirrors [`AgentDialogTurnRequest`] but makes `policy` optional on the -/// wire: web clients (and any caller that does not select a dialog policy) -/// omit it and the server substitutes the desktop default -/// [`DialogSubmissionPolicy::for_source`]`(AgentSubmissionSource::DesktopUi)`, -/// matching how the desktop host synthesizes it (`agentic_api.rs`). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[request(method = "agent/submitDialogTurn", response = SubmitDialogTurnResponse)] -pub struct SubmitDialogTurnMessage(pub SubmitDialogTurnBody); - -/// Wire form of [`AgentDialogTurnRequest`] with an optional `policy`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[serde(rename_all = "camelCase")] -pub struct SubmitDialogTurnBody { - pub session_id: String, - pub message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub original_message: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub turn_id: Option, - #[serde(default, skip_serializing_if = "AgentDialogTurnExecution::is_standard")] - pub execution: AgentDialogTurnExecution, - pub agent_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_connection_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_ssh_host: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attachments: Vec, - #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] - pub metadata: serde_json::Map, -} - -impl SubmitDialogTurnBody { - /// Build the runtime request, defaulting `policy` to the desktop UI source - /// when the caller omitted it, and passing through the remaining fields. - /// - /// `reply_route` and `prepended_reminders` are **not** on the wire body - /// struct, so they are intentionally absent from the client contract. - /// They are runtime-internal fields on `AgentDialogTurnRequest` and are - /// defaulted here; a client cannot send them and they cannot be silently - /// dropped because they are never accepted by deserialization. - pub fn to_request(self) -> AgentDialogTurnRequest { - AgentDialogTurnRequest { - session_id: self.session_id, - message: self.message, - original_message: self.original_message, - turn_id: self.turn_id, - execution: self.execution, - agent_type: self.agent_type, - workspace_path: self.workspace_path, - remote_connection_id: self.remote_connection_id, - remote_ssh_host: self.remote_ssh_host, - policy: self.policy.unwrap_or_else(|| { - DialogSubmissionPolicy::for_source(AgentSubmissionSource::DesktopUi) - }), - reply_route: None, - prepended_reminders: Vec::new(), - attachments: self.attachments, - metadata: self.metadata, - } - } -} - -/// `agent/submitDialogTurn` response body, mapped from the non-serde -/// [`DialogSubmitOutcome`] (which only derives `Debug`/`Clone`/`PartialEq`/`Eq`). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[serde(rename_all = "camelCase", tag = "status")] -pub enum SubmitDialogTurnResponse { - Started { session_id: String, turn_id: String }, - Queued { session_id: String, turn_id: String }, -} - -impl SubmitDialogTurnResponse { - pub fn from_outcome(outcome: DialogSubmitOutcome) -> Self { - match outcome { - DialogSubmitOutcome::Started { - session_id, - turn_id, - } => Self::Started { - session_id, - turn_id, - }, - DialogSubmitOutcome::Queued { - session_id, - turn_id, - } => Self::Queued { - session_id, - turn_id, - }, - } - } -} - -// Permission surface ---------------------------------------------------------- -// -// These map the `AgentRuntime` permission SDK (`pending_permission_requests`, -// `subscribe_permission_requests`, `respond_permission(_batch)`, project grants -// + audit) onto the app-server wire. The runtime already holds the permission -// manager, so the host injects it as usual via `BitfunAppRuntime`; the -// `respondPermission`/`respondPermissionBatch`/`listPendingPermissionRequests` -// commands are driven from here, and `agent/permissionEvent` notifications carry -// the inbound `PermissionRequestEvent` stream to the client (the desktop host -// today emits these to the UI via `app.emit("permission://event")`; the -// app-server forwards them over the transport instead). - -/// `agent/respondPermission` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "agent/respondPermission", response = RespondPermissionResponse)] -pub struct RespondPermissionMessage { - pub request_id: String, - pub reply: PermissionReply, -} - -/// `agent/respondPermission` response body (the SDK returns `()`). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct RespondPermissionResponse {} - -/// `agent/respondPermissionBatch` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request( - method = "agent/respondPermissionBatch", - response = RespondPermissionBatchResponse -)] -pub struct RespondPermissionBatchMessage { - pub request_id: String, - pub reply: PermissionReply, -} - -/// `agent/respondPermissionBatch` response body: the request ids that shared -/// the resolved reply. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct RespondPermissionBatchResponse { - pub request_ids: Vec, -} - -/// `agent/listPendingPermissionRequests` request body (no parameters). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request( - method = "agent/listPendingPermissionRequests", - response = ListPendingPermissionRequestsResponse -)] -pub struct ListPendingPermissionRequestsMessage {} - -/// `agent/listPendingPermissionRequests` response body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct ListPendingPermissionRequestsResponse { - pub requests: Vec, -} - -/// `agent/listProjectPermissionGrants` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request( - method = "agent/listProjectPermissionGrants", - response = ListProjectPermissionGrantsResponse -)] -pub struct ListProjectPermissionGrantsMessage { - pub project_id: String, -} - -/// `agent/listProjectPermissionGrants` response body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct ListProjectPermissionGrantsResponse { - pub grants: Vec, -} - -/// `agent/removeProjectPermissionGrant` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[request( - method = "agent/removeProjectPermissionGrant", - response = RemoveProjectPermissionGrantResponse -)] -pub struct RemoveProjectPermissionGrantMessage(pub PermissionGrantKey); - -/// `agent/removeProjectPermissionGrant` response body: whether a grant was -/// removed. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct RemoveProjectPermissionGrantResponse { - pub removed: bool, -} - -/// `agent/clearProjectPermissionGrants` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request( - method = "agent/clearProjectPermissionGrants", - response = ClearProjectPermissionGrantsResponse -)] -pub struct ClearProjectPermissionGrantsMessage { - pub project_id: String, -} - -/// `agent/clearProjectPermissionGrants` response body: how many grants cleared. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct ClearProjectPermissionGrantsResponse { - pub cleared: usize, -} - -/// `agent/permissionEvent` notification: a permission lifecycle event forwarded -/// to the client. The server drains the runtime permission receiver (the same -/// stream the desktop host emits as `permission://event`) and forwards each -/// [`PermissionRequestEvent`] over the transport; the client fans it out to its -/// consumers. This keeps the permission stream on the app-server protocol surface. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[notification(method = "agent/permissionEvent")] -pub struct PermissionEventNotification(pub PermissionRequestEvent); - -/// `agent/listProjectPermissionAudit` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request( - method = "agent/listProjectPermissionAudit", - response = ListProjectPermissionAuditResponse -)] -pub struct ListProjectPermissionAuditMessage { - pub project_id: String, -} - -/// `agent/listProjectPermissionAudit` response body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct ListProjectPermissionAuditResponse { - pub records: Vec, -} - -/// `agent/cancelTurn` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[request(method = "agent/cancelTurn", response = CancelTurnResponse)] -pub struct CancelTurnMessage(pub AgentTurnCancellationRequest); - -/// `agent/cancelTurn` response body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -pub struct CancelTurnResponse(pub AgentTurnCancellationResult); - -/// `agent/run` request body. `SessionSelector` (in `agent-runtime`) does not -/// derive serde, so the wire form is a discriminated union that maps to it. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "agent/run", response = RunResponse)] -pub struct RunMessage { - pub session: RunSessionSpec, - pub message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source: Option, -} - -/// Wire form of [`SessionSelector`]. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[serde(rename_all = "camelCase", tag = "kind")] -pub enum RunSessionSpec { - Existing { - session_id: String, - }, - Create { - session_name: String, - agent_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - workspace_path: Option, - }, -} - -impl RunSessionSpec { - pub fn to_selector(&self) -> SessionSelector { - match self { - RunSessionSpec::Existing { session_id } => SessionSelector::existing(session_id), - RunSessionSpec::Create { - session_name, - agent_type, - workspace_path, - } => SessionSelector::create(session_name, agent_type, workspace_path.clone()), - } - } -} - -/// `agent/run` response body, mapped from `AgentRunHandle` (which does not -/// derive serde). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[serde(rename_all = "camelCase")] -pub struct RunResponse { - pub session_id: String, - pub turn_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_type: Option, - #[serde(default)] - pub accepted: bool, -} - -impl RunResponse { - pub fn from_handle(handle: AgentRunHandle) -> Self { - Self { - session_id: handle.session_id, - turn_id: handle.turn_id, - agent_type: handle.agent_type, - accepted: handle.accepted, - } - } -} - -impl RunMessage { - pub fn to_run_request(&self) -> AgentRunRequest { - let mut req = AgentRunRequest::new(self.session.to_selector(), &self.message); - if let Some(turn_id) = &self.turn_id { - req = req.with_turn_id(turn_id); - } - if let Some(source) = self.source { - req = req.with_source(source); - } - req - } -} - -/// `agent/event` notification: a runtime event forwarded to the client. -/// -/// `AgenticEventEnvelope` is the exact type the runtime event queue -/// broadcasts to subscribers, and it derives serde. The server forwards each -/// envelope it receives from its injected [`AgentEventSource`] to the client -/// over the app-server transport; the client registers `on_receive_notification` -/// to receive them. This keeps the event stream on the app-server protocol -/// surface instead of letting the client subscribe to the runtime queue directly. -/// -/// [`AgentEventSource`]: bitfun_agent_runtime::sdk::AgentEventSource -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] -// NOTE(1a): `AgenticEventEnvelope` (bitfun-events) is not yet `TS`-derivable; -// the `agent/event` notification surface is exported in Step 1 Phase 1b once -// the events crate derives `TS` (see docs/plans/step1-ts-rs-integration.md Sec. 5). -#[notification(method = "agent/event")] -pub struct SessionEventNotification(pub AgenticEventEnvelope); - -/// `agent/frontendEvent` notification: a runtime or permission event projected -/// to the frontend shape (`agentic://` / `permission://event`) and pushed -/// to the browser by the server's `serve` main loop. Carrying the projected -/// `event` name and `payload` lets the browser `listen(event)` dispatch on the -/// same names it uses today, with zero call-site change. This is the -/// browser-facing event surface under browser-direct ACP-over-WS (Step 2). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[notification(method = "agent/frontendEvent")] -pub struct FrontendEventNotification { - /// Frontend event name (e.g. `agentic://session-created`, `permission://event`). - pub event: String, - /// Projected payload, already in the frontend's expected shape. - pub payload: serde_json::Value, -} - -// Git service surface --------------------------------------------------------- -// -// Under option C the app-server schema owns the full backend contract, not just -// agent-kernel ops. These `git/*` messages expose the read-only `GitService` -// operations (`bitfun_core::service::git::GitService`, which re-exports -// `bitfun_services_integrations::git::GitService`). The handlers call the -// static `GitService::xxx(&path)` associated functions the same way the -// Desktop host's 583 Tauri commands do -- no service injection is needed for -// static services, only the lifecycle-bound singletons (coordinator/scheduler) -// require injection and those land with the agent-control batches. -// -// Request bodies mirror the Desktop Tauri request types: camelCase wire fields -// (`#[serde(rename_all = "camelCase")]`) so the frontend `GitAPI` call sites -// (`api.invoke('git_get_status', { request: { repositoryPath } })`) deserialize -// unchanged. Responses reuse the core types directly -- they already derive -// serde with snake_case field names, which the frontend `GitStatus`/`GitBranch` -// TS interfaces also use, so no wire wrapping is needed on the response side. -// The method names use the `group/verb` camelCase convention (`git/isRepository`, -// `git/getStatus`) matching the existing `agent/createSession` style; the -// websocket adapter (`websocket-adapter.ts::AGENT_COMMAND_TO_WS_METHOD`) -// translates the frontend snake_case command name to this method. -// -// Scope: read-only operations only in this batch. Write operations -// (`git/addFiles`, `git/commit`, `git/push`, ...) and the remote (SSH) path -// arrive in later batches; the Server Host has no SSH manager, so remote git -// paths surface as `host_capability_unavailable` (the `external_sources` write -// precedent). - -/// `git/isRepository` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[request(method = "git/isRepository", response = GitIsRepositoryResponse)] -pub struct GitIsRepositoryMessage(pub GitRepositoryPathRequest); - -/// `git/isRepository` response body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -pub struct GitIsRepositoryResponse(pub bool); - -/// `git/getStatus` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[request(method = "git/getStatus", response = GitGetStatusResponse)] -pub struct GitGetStatusMessage(pub GitRepositoryPathRequest); - -/// `git/getStatus` response body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -pub struct GitGetStatusResponse(pub bitfun_core::service::git::GitStatus); - -/// `git/getBranches` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[request(method = "git/getBranches", response = GitGetBranchesResponse)] -pub struct GitGetBranchesMessage(pub GitBranchesRequest); - -/// `git/getBranches` response body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct GitGetBranchesResponse { - pub branches: Vec, -} - -/// Common camelCase wire shape for a single repository-path request. Mirrors -/// the Desktop `GitRepositoryRequest` (`rename_all = "camelCase"`) so the -/// frontend payload deserializes without field renaming at the call site. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[serde(rename_all = "camelCase")] -pub struct GitRepositoryPathRequest { - pub repository_path: String, -} - -/// `git/getBranches` wire request: repository path plus the optional -/// `includeRemote` flag. The core `GitService::get_branches` takes a bare `bool`, -/// so an omitted flag defaults to `false` in the handler. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[serde(rename_all = "camelCase")] -pub struct GitBranchesRequest { - pub repository_path: String, - #[serde(default)] - pub include_remote: Option, -} - -// Config service surface ----------------------------------------------------- -// -// Read-only `ConfigService` / agent-profile canonicalizer operations. Under -// option C these live on the app-server surface alongside the agent-kernel -// and `git/*` groups. The handlers call the global config singletons the same -// way the Desktop host does -- `bitfun_core::service::config::get_global_config_service` -// (an `Arc` initialized by the host's bootstrap) and the static -// `mode_config_canonicalizer::get_agent_profile_views` -- so no service -// injection is needed, mirroring the static `GitService` pattern. -// -// This batch scopes to the read-only config operations: `getAgentProfileConfigs` -// /`getAgentProfileConfig` (pure canonicalizer), `getModelConfigs` -// (`config_service.get_ai_models`), and `getConfig`/`getConfigs` -// (`config_service.get_config::`). `getConfig`/`getConfigs` carry the -// "config path not found" retry contract the frontend depends on -// ([ConfigAPI.ts] returns `undefined` on matching errors): the -// [`crate::agent::config_get_error`] helper puts the `BitFunError::NotFound` -// Display text into the JSON-RPC `message` (not just `data`) so the frontend -// substring match hits in web mode the same way it does on desktop. The -// `skipRetryOnNotFound` request field is accepted for contract parity and -// otherwise ignored -- the app-server does not retry; the field steers the -// frontend `ApiClient` retry policy and desktop-side logging. -// `get_skill_configs` depends on the workspace service and lands in a later -// batch. - -/// `config/getAgentProfileConfigs` request body (no parameters). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request( - method = "config/getAgentProfileConfigs", - response = GetAgentProfileConfigsResponse -)] -pub struct GetAgentProfileConfigsMessage {} - -/// `config/getAgentProfileConfigs` response body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct GetAgentProfileConfigsResponse { - pub profiles: std::collections::HashMap, -} - -/// `config/getAgentProfileConfig` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request( - method = "config/getAgentProfileConfig", - response = GetAgentProfileConfigResponse -)] -pub struct GetAgentProfileConfigMessage { - pub agent_id: String, -} - -/// `config/getAgentProfileConfig` response body. The canonicalizer returns a -/// bare `AgentProfileView` (erroring when the id is unknown), so the wire form -/// is a single value -- no `Option` wrapper. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -pub struct GetAgentProfileConfigResponse(pub bitfun_core::service::config::AgentProfileView); - -/// `config/getModelConfigs` request body (no parameters). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -// NOTE(1a): deferred to Phase 1b -- `AIModelConfig` carries a `#[serde(from = -// "AIModelConfigCompat")]` migration shim that must derive `TS` first (so the -// generated type matches the deserialized shape, not the in-memory struct). -#[request(method = "config/getModelConfigs", response = GetModelConfigsResponse)] -pub struct GetModelConfigsMessage {} - -/// `config/getModelConfigs` response body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -pub struct GetModelConfigsResponse { - pub models: Vec, -} - -/// `config/getConfig` request body. Mirrors the desktop `GetConfigRequest` -/// (`rename_all = "camelCase"`): `path` is optional (a missing path reads the -/// whole config tree). `skipRetryOnNotFound` is accepted for contract parity -/// and otherwise ignored by the app-server (it does not retry; the field -/// steers the frontend `ApiClient` retry policy and desktop-side logging). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "config/getConfig", response = GetConfigResponse)] -#[serde(rename_all = "camelCase")] -pub struct GetConfigMessage { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option, - #[serde(default, skip_serializing_if = "skip_if_false")] - pub skip_retry_on_not_found: bool, -} - -/// `config/getConfig` response body. The config value is an arbitrary JSON -/// tree, so it is surfaced as `serde_json::Value` (the desktop host returns -/// `Value` too -- `config_service.get_config::(path)`). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -pub struct GetConfigResponse(pub serde_json::Value); - -/// `config/getConfigs` request body. Mirrors the desktop `GetConfigsRequest` -/// (`rename_all = "camelCase"`): a list of paths to read in one batch. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "config/getConfigs", response = GetConfigsResponse)] -#[serde(rename_all = "camelCase")] -pub struct GetConfigsMessage { - pub paths: Vec, - #[serde(default, skip_serializing_if = "skip_if_false")] - pub skip_retry_on_not_found: bool, -} - -/// `config/getConfigs` response body. Maps to the desktop -/// `BTreeMap` shape; the handler dedupes paths the same way the -/// desktop host does (`config_api.rs::get_configs` skips a path already seen). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct GetConfigsResponse { - pub configs: std::collections::BTreeMap, -} - -/// `config/setConfig` request body (Track B): writes a value at a config path. -/// Mirrors the desktop `SetConfigRequest` (`rename_all = "camelCase"`). The -/// handler reaches the global config singleton (`get_global_config_service`), -/// the same way the Desktop host does -- no service injection. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "config/setConfig", response = SetConfigResponse)] -#[serde(rename_all = "camelCase")] -pub struct SetConfigMessage { - pub path: String, - pub value: serde_json::Value, -} - -/// `config/setConfig` response body (the config service returns `()`). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct SetConfigResponse {} - -// I18n service surface (Track B) --------------------------------------------- -// -// Read/write the runtime locale via the global config singleton -// (`app.language`) and the global `I18nService` (`sync_global_i18n_service_locale`). -// Locale identifiers are surfaced as plain strings (`zh-CN`, `en-US`, ...) to -// avoid deriving `TS` on the i18n crate's `LocaleId`/`LocaleMetadata` types; -// the supported-languages response carries a project-local wire struct. - -/// `i18n/getCurrentLanguage` response body (no parameters -> uses a -/// `JsonRpcRequest` with an empty body + a typed response). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "i18n/getCurrentLanguage", response = I18nGetCurrentLanguageResponse)] -pub struct I18nGetCurrentLanguageMessage {} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct I18nGetCurrentLanguageResponse { - /// BCP-47-ish locale id, e.g. `zh-CN`. Defaults to `zh-CN` when unset. - pub language: String, -} - -/// `i18n/setLanguage` request body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "i18n/setLanguage", response = I18nSetLanguageResponse)] -#[serde(rename_all = "camelCase")] -pub struct I18nSetLanguageMessage { - pub language: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct I18nSetLanguageResponse { - /// The locale id that was applied (validated against the supported set). - pub language: String, -} - -/// `i18n/getConfig` request body (no parameters). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "i18n/getConfig", response = I18nGetConfigResponse)] -pub struct I18nGetConfigMessage {} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct I18nGetConfigResponse { - pub current_language: String, - pub fallback_language: String, - pub auto_detect: bool, -} - -/// `i18n/setConfig` request body: writes `currentLanguage` and syncs the global -/// I18nService. `fallbackLanguage`/`autoDetect` are accepted for contract parity -/// and otherwise ignored (the runtime does not yet store them). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "i18n/setConfig", response = I18nSetConfigResponse)] -#[serde(rename_all = "camelCase")] -pub struct I18nSetConfigMessage { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_language: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fallback_language: Option, - #[serde(default)] - pub auto_detect: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct I18nSetConfigResponse {} - -/// `i18n/getSupportedLanguages` request body (no parameters). -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "i18n/getSupportedLanguages", response = I18nGetSupportedLanguagesResponse)] -pub struct I18nGetSupportedLanguagesMessage {} - -/// One supported locale's metadata, projected from `bitfun_core::service::i18n::LocaleMetadata`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[serde(rename_all = "camelCase")] -pub struct I18nLocaleMetadata { - pub id: String, - pub name: String, - pub english_name: String, - pub native_name: String, - pub rtl: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -pub struct I18nGetSupportedLanguagesResponse { - pub locales: Vec, -} - -/// Serializes a `bool` only when it is `true`, so the default `false` for -/// `skipRetryOnNotFound` is omitted from the request wire form (matching the -/// desktop host's `#[serde(default)]` request shape, which also omits it when -/// false). -fn skip_if_false(value: &bool) -> bool { - !*value -} diff --git a/src/crates/interfaces/app-server/src/schema/agent.rs b/src/crates/interfaces/app-server/src/schema/agent.rs new file mode 100644 index 0000000000..cb9fcfc937 --- /dev/null +++ b/src/crates/interfaces/app-server/src/schema/agent.rs @@ -0,0 +1,234 @@ +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use bitfun_agent_runtime::sdk::{ + AgentDialogTurnExecution, AgentDialogTurnRequest, AgentInputAttachment, AgentRunHandle, + AgentRunRequest, AgentSessionCreateRequest, AgentSessionCreateResult, + AgentSessionDeleteRequest, AgentSessionListRequest, AgentSessionSummary, + AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, + AgentTurnCancellationRequest, AgentTurnCancellationResult, DialogSubmissionPolicy, + DialogSubmitOutcome, SessionSelector, +}; +use serde::{Deserialize, Serialize}; + +/// `agent/createSession` request body (wraps the port request type). +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[request(method = "agent/createSession", response = CreateSessionResponse)] +pub struct CreateSessionMessage(pub AgentSessionCreateRequest); + +/// `agent/createSession` response body (wraps the port result type). +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct CreateSessionResponse(pub AgentSessionCreateResult); + +/// `agent/listSessions` request body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[request(method = "agent/listSessions", response = ListSessionsResponse)] +pub struct ListSessionsMessage(pub AgentSessionListRequest); + +/// `agent/listSessions` response body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct ListSessionsResponse { + pub sessions: Vec, +} + +/// `agent/deleteSession` request body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[request(method = "agent/deleteSession", response = DeleteSessionResponse)] +pub struct DeleteSessionMessage(pub AgentSessionDeleteRequest); + +/// `agent/deleteSession` response body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct DeleteSessionResponse {} + +/// `agent/submitTurn` request body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[request(method = "agent/submitTurn", response = SubmitTurnResponse)] +pub struct SubmitTurnMessage(pub AgentSubmissionRequest); + +/// `agent/submitTurn` response body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct SubmitTurnResponse(pub AgentSubmissionResult); + +/// `agent/submitDialogTurn` request body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[request(method = "agent/submitDialogTurn", response = SubmitDialogTurnResponse)] +pub struct SubmitDialogTurnMessage(pub SubmitDialogTurnBody); + +/// Wire form of [`AgentDialogTurnRequest`] with an optional `policy`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct SubmitDialogTurnBody { + pub session_id: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original_message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "AgentDialogTurnExecution::is_standard")] + pub execution: AgentDialogTurnExecution, + pub agent_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attachments: Vec, + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub metadata: serde_json::Map, +} + +impl SubmitDialogTurnBody { + /// Build the runtime request, defaulting `policy` to the desktop UI source. + pub fn to_request(self) -> AgentDialogTurnRequest { + AgentDialogTurnRequest { + session_id: self.session_id, + message: self.message, + original_message: self.original_message, + turn_id: self.turn_id, + execution: self.execution, + agent_type: self.agent_type, + workspace_path: self.workspace_path, + remote_connection_id: self.remote_connection_id, + remote_ssh_host: self.remote_ssh_host, + policy: self.policy.unwrap_or_else(|| { + DialogSubmissionPolicy::for_source(AgentSubmissionSource::DesktopUi) + }), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: self.attachments, + metadata: self.metadata, + } + } +} + +/// `agent/submitDialogTurn` response body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase", tag = "status")] +pub enum SubmitDialogTurnResponse { + Started { session_id: String, turn_id: String }, + Queued { session_id: String, turn_id: String }, +} + +impl SubmitDialogTurnResponse { + pub fn from_outcome(outcome: DialogSubmitOutcome) -> Self { + match outcome { + DialogSubmitOutcome::Started { + session_id, + turn_id, + } => Self::Started { + session_id, + turn_id, + }, + DialogSubmitOutcome::Queued { + session_id, + turn_id, + } => Self::Queued { + session_id, + turn_id, + }, + } + } +} + +/// `agent/cancelTurn` request body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[request(method = "agent/cancelTurn", response = CancelTurnResponse)] +pub struct CancelTurnMessage(pub AgentTurnCancellationRequest); + +/// `agent/cancelTurn` response body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct CancelTurnResponse(pub AgentTurnCancellationResult); + +/// `agent/run` request body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "agent/run", response = RunResponse)] +pub struct RunMessage { + pub session: RunSessionSpec, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +/// Wire form of [`SessionSelector`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum RunSessionSpec { + Existing { + session_id: String, + }, + Create { + session_name: String, + agent_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_path: Option, + }, +} + +impl RunSessionSpec { + pub fn to_selector(&self) -> SessionSelector { + match self { + RunSessionSpec::Existing { session_id } => SessionSelector::existing(session_id), + RunSessionSpec::Create { + session_name, + agent_type, + workspace_path, + } => SessionSelector::create(session_name, agent_type, workspace_path.clone()), + } + } +} + +/// `agent/run` response body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct RunResponse { + pub session_id: String, + pub turn_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_type: Option, + #[serde(default)] + pub accepted: bool, +} + +impl RunResponse { + pub fn from_handle(handle: AgentRunHandle) -> Self { + Self { + session_id: handle.session_id, + turn_id: handle.turn_id, + agent_type: handle.agent_type, + accepted: handle.accepted, + } + } +} + +impl RunMessage { + pub fn to_run_request(&self) -> AgentRunRequest { + let mut request = AgentRunRequest::new(self.session.to_selector(), &self.message); + if let Some(turn_id) = &self.turn_id { + request = request.with_turn_id(turn_id); + } + if let Some(source) = self.source { + request = request.with_source(source); + } + request + } +} diff --git a/src/crates/interfaces/app-server/src/schema/app.rs b/src/crates/interfaces/app-server/src/schema/app.rs new file mode 100644 index 0000000000..80e9b2d6af --- /dev/null +++ b/src/crates/interfaces/app-server/src/schema/app.rs @@ -0,0 +1,3 @@ +//! Behavior-light application lifecycle contracts. + +pub use bitfun_app_server_protocol::app::*; diff --git a/src/crates/interfaces/app-server/src/schema/config.rs b/src/crates/interfaces/app-server/src/schema/config.rs new file mode 100644 index 0000000000..5827c087cb --- /dev/null +++ b/src/crates/interfaces/app-server/src/schema/config.rs @@ -0,0 +1,106 @@ +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "config/getAgentProfileConfigs", response = GetAgentProfileConfigsResponse)] +pub struct GetAgentProfileConfigsMessage {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct GetAgentProfileConfigsResponse { + pub profiles: std::collections::HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "config/getAgentProfileConfig", response = GetAgentProfileConfigResponse)] +pub struct GetAgentProfileConfigMessage { + pub agent_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct GetAgentProfileConfigResponse(pub bitfun_core::service::config::AgentProfileView); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "config/getModelConfigs", response = GetModelConfigsResponse)] +pub struct GetModelConfigsMessage {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct GetModelConfigsResponse { + pub models: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "config/getConfig", response = GetConfigResponse)] +#[serde(rename_all = "camelCase")] +pub struct GetConfigMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "skip_if_false")] + pub skip_retry_on_not_found: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct GetConfigResponse(pub serde_json::Value); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "config/getConfigs", response = GetConfigsResponse)] +#[serde(rename_all = "camelCase")] +pub struct GetConfigsMessage { + pub paths: Vec, + #[serde(default, skip_serializing_if = "skip_if_false")] + pub skip_retry_on_not_found: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct GetConfigsResponse { + pub configs: std::collections::BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "config/setConfig", response = SetConfigResponse)] +#[serde(rename_all = "camelCase")] +pub struct SetConfigMessage { + pub path: String, + pub value: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct SetConfigResponse {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "config/setAgentProfileConfig", response = SetAgentProfileConfigResponse)] +#[serde(rename_all = "camelCase")] +pub struct SetAgentProfileConfigMessage { + pub agent_id: String, + pub config: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct SetAgentProfileConfigResponse(pub bitfun_core::service::config::AgentProfileView); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "config/resetAgentProfileConfig", response = ResetAgentProfileConfigResponse)] +#[serde(rename_all = "camelCase")] +pub struct ResetAgentProfileConfigMessage { + pub agent_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct ResetAgentProfileConfigResponse(pub bitfun_core::service::config::AgentProfileView); + +fn skip_if_false(value: &bool) -> bool { + !*value +} diff --git a/src/crates/interfaces/app-server/src/schema/events.rs b/src/crates/interfaces/app-server/src/schema/events.rs new file mode 100644 index 0000000000..14242595c4 --- /dev/null +++ b/src/crates/interfaces/app-server/src/schema/events.rs @@ -0,0 +1,116 @@ +use agent_client_protocol::JsonRpcNotification; +use serde::{Deserialize, Serialize}; + +pub use bitfun_app_server_protocol::event::{ + AgentEventNotification as SessionEventNotification, ConfigEventNotification, ConfigUpdate, + EventCursor, EventStream, EventStreamState, EventStreamStateNotification, ResyncDirective, + SyncEventsRequest, SyncEventsResponse, +}; + +/// Browser-facing projected runtime or permission event. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[notification(method = "agent/frontendEvent")] +pub struct FrontendEventNotification { + /// Frontend event name, such as `agentic://session-created`. + pub event: String, + /// Projected payload in the frontend's expected shape. + pub payload: serde_json::Value, +} + +pub(crate) fn config_update_from_owner( + value: bitfun_core::service::config::ConfigUpdateEvent, +) -> ConfigUpdate { + use bitfun_core::service::config::ConfigUpdateEvent; + + match value { + ConfigUpdateEvent::ModelConfigurationUpdated => ConfigUpdate::ModelConfigurationUpdated, + ConfigUpdateEvent::AIModelUpdated { + model_id, + model_name, + } => ConfigUpdate::AiModelUpdated { + model_id, + model_name, + }, + ConfigUpdateEvent::DefaultAIModelUpdated { + model_id, + model_name, + } => ConfigUpdate::DefaultAiModelUpdated { + model_id, + model_name, + }, + ConfigUpdateEvent::AppearanceUpdated { appearance_id } => { + ConfigUpdate::AppearanceUpdated { appearance_id } + } + ConfigUpdateEvent::EditorUpdated => ConfigUpdate::EditorUpdated, + ConfigUpdateEvent::TerminalUpdated => ConfigUpdate::TerminalUpdated, + ConfigUpdateEvent::WorkspaceUpdated => ConfigUpdate::WorkspaceUpdated, + ConfigUpdateEvent::AppUpdated => ConfigUpdate::AppUpdated, + ConfigUpdateEvent::ConfigReloaded => ConfigUpdate::ConfigReloaded, + ConfigUpdateEvent::DebugModeConfigUpdated { + new_port, + new_log_path, + } => ConfigUpdate::DebugModeConfigUpdated { + new_port, + new_log_path, + }, + ConfigUpdateEvent::LogLevelUpdated { new_level } => { + ConfigUpdate::LogLevelUpdated { new_level } + } + ConfigUpdateEvent::LoggingSensitiveDiagnosticsUpdated { + include_sensitive_diagnostics, + } => ConfigUpdate::LoggingSensitiveDiagnosticsUpdated { + include_sensitive_diagnostics, + }, + ConfigUpdateEvent::ModelsReconciled { + invalidated_model_ids, + default_models_changed, + func_agent_models_changed, + agent_model_defaults_changed, + } => ConfigUpdate::ModelsReconciled { + invalidated_model_ids, + default_models_changed, + func_agent_models_changed, + agent_model_defaults_changed, + }, + } +} + +#[cfg(test)] +mod tests { + use bitfun_core::service::config::ConfigUpdateEvent; + use serde_json::json; + + #[test] + fn models_reconciled_preserves_all_owner_facts() { + let update = super::config_update_from_owner(ConfigUpdateEvent::ModelsReconciled { + invalidated_model_ids: vec!["model-1".to_string()], + default_models_changed: true, + func_agent_models_changed: false, + agent_model_defaults_changed: true, + }); + + assert_eq!( + serde_json::to_value(update).expect("config update should serialize"), + json!({ + "kind": "modelsReconciled", + "invalidatedModelIds": ["model-1"], + "defaultModelsChanged": true, + "funcAgentModelsChanged": false, + "agentModelDefaultsChanged": true + }) + ); + } +} + +#[cfg(all(test, feature = "ts"))] +mod ts_exports { + use super::ConfigUpdate; + use ts_rs::{Config, TS}; + + #[test] + fn export_upstream_config_update() { + ConfigUpdate::export(&Config::from_env()) + .expect("ConfigUpdate TypeScript export should succeed"); + } +} diff --git a/src/crates/interfaces/app-server/src/schema/git.rs b/src/crates/interfaces/app-server/src/schema/git.rs new file mode 100644 index 0000000000..7a960acc1e --- /dev/null +++ b/src/crates/interfaces/app-server/src/schema/git.rs @@ -0,0 +1,47 @@ +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[request(method = "git/isRepository", response = GitIsRepositoryResponse)] +pub struct GitIsRepositoryMessage(pub GitRepositoryPathRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct GitIsRepositoryResponse(pub bool); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[request(method = "git/getStatus", response = GitGetStatusResponse)] +pub struct GitGetStatusMessage(pub GitRepositoryPathRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct GitGetStatusResponse(pub bitfun_core::service::git::GitStatus); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[request(method = "git/getBranches", response = GitGetBranchesResponse)] +pub struct GitGetBranchesMessage(pub GitBranchesRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct GitGetBranchesResponse { + pub branches: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct GitRepositoryPathRequest { + pub repository_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct GitBranchesRequest { + pub repository_path: String, + #[serde(default)] + pub include_remote: Option, +} diff --git a/src/crates/interfaces/app-server/src/schema/i18n.rs b/src/crates/interfaces/app-server/src/schema/i18n.rs new file mode 100644 index 0000000000..c84a04de7a --- /dev/null +++ b/src/crates/interfaces/app-server/src/schema/i18n.rs @@ -0,0 +1,79 @@ +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "i18n/getCurrentLanguage", response = I18nGetCurrentLanguageResponse)] +pub struct I18nGetCurrentLanguageMessage {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct I18nGetCurrentLanguageResponse { + pub language: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "i18n/setLanguage", response = I18nSetLanguageResponse)] +#[serde(rename_all = "camelCase")] +pub struct I18nSetLanguageMessage { + pub language: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct I18nSetLanguageResponse { + pub language: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "i18n/getConfig", response = I18nGetConfigResponse)] +pub struct I18nGetConfigMessage {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct I18nGetConfigResponse { + pub current_language: String, + pub fallback_language: String, + pub auto_detect: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "i18n/setConfig", response = I18nSetConfigResponse)] +#[serde(rename_all = "camelCase")] +pub struct I18nSetConfigMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback_language: Option, + #[serde(default)] + pub auto_detect: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct I18nSetConfigResponse {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "i18n/getSupportedLanguages", response = I18nGetSupportedLanguagesResponse)] +pub struct I18nGetSupportedLanguagesMessage {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct I18nLocaleMetadata { + pub id: String, + pub name: String, + pub english_name: String, + pub native_name: String, + pub rtl: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct I18nGetSupportedLanguagesResponse { + pub locales: Vec, +} diff --git a/src/crates/interfaces/app-server/src/schema/mod.rs b/src/crates/interfaces/app-server/src/schema/mod.rs new file mode 100644 index 0000000000..166034b205 --- /dev/null +++ b/src/crates/interfaces/app-server/src/schema/mod.rs @@ -0,0 +1,23 @@ +//! JSON-RPC wire contract for the BitFun app-server surface. +//! +//! Schema types are grouped by product domain while this module re-exports the +//! complete contract. Existing consumers can continue importing types from +//! `bitfun_app_server::schema` without depending on the internal layout. + +mod agent; +mod app; +mod config; +mod events; +mod git; +mod i18n; +mod permission; +mod session; + +pub use agent::*; +pub use app::*; +pub use config::*; +pub use events::*; +pub use git::*; +pub use i18n::*; +pub use permission::*; +pub use session::*; diff --git a/src/crates/interfaces/app-server/src/schema/permission.rs b/src/crates/interfaces/app-server/src/schema/permission.rs new file mode 100644 index 0000000000..b3178c7a3a --- /dev/null +++ b/src/crates/interfaces/app-server/src/schema/permission.rs @@ -0,0 +1,95 @@ +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use bitfun_agent_runtime::sdk::{ + PermissionAuditRecord, PermissionGrant, PermissionGrantKey, PermissionReply, PermissionRequest, +}; +use serde::{Deserialize, Serialize}; + +/// `agent/respondPermission` request body. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "agent/respondPermission", response = RespondPermissionResponse)] +pub struct RespondPermissionMessage { + pub request_id: String, + pub reply: PermissionReply, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct RespondPermissionResponse {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "agent/respondPermissionBatch", response = RespondPermissionBatchResponse)] +pub struct RespondPermissionBatchMessage { + pub request_id: String, + pub reply: PermissionReply, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct RespondPermissionBatchResponse { + pub request_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "agent/listPendingPermissionRequests", response = ListPendingPermissionRequestsResponse)] +pub struct ListPendingPermissionRequestsMessage {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct ListPendingPermissionRequestsResponse { + pub requests: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "agent/listProjectPermissionGrants", response = ListProjectPermissionGrantsResponse)] +pub struct ListProjectPermissionGrantsMessage { + pub project_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct ListProjectPermissionGrantsResponse { + pub grants: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[request(method = "agent/removeProjectPermissionGrant", response = RemoveProjectPermissionGrantResponse)] +pub struct RemoveProjectPermissionGrantMessage(pub PermissionGrantKey); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct RemoveProjectPermissionGrantResponse { + pub removed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "agent/clearProjectPermissionGrants", response = ClearProjectPermissionGrantsResponse)] +pub struct ClearProjectPermissionGrantsMessage { + pub project_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct ClearProjectPermissionGrantsResponse { + pub cleared: usize, +} + +pub use bitfun_app_server_protocol::event::PermissionEventNotification; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "agent/listProjectPermissionAudit", response = ListProjectPermissionAuditResponse)] +pub struct ListProjectPermissionAuditMessage { + pub project_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct ListProjectPermissionAuditResponse { + pub records: Vec, +} diff --git a/src/crates/interfaces/app-server/src/schema/session.rs b/src/crates/interfaces/app-server/src/schema/session.rs new file mode 100644 index 0000000000..9b89b1f716 --- /dev/null +++ b/src/crates/interfaces/app-server/src/schema/session.rs @@ -0,0 +1,209 @@ +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use bitfun_agent_runtime::sdk::{ + AgentSessionArchiveStateRequest, AgentSessionForkAtTurnRequest, + AgentSessionForkBeforeTurnRequest, AgentSessionForkRequest, AgentSessionForkResult, + AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRenameRequest, +}; +use serde::{Deserialize, Serialize}; + +macro_rules! empty_response { + ($name:ident) => { + #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] + #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] + pub struct $name {} + }; +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "session/rename", response = RenameSessionResponse)] +pub struct RenameSessionMessage(pub AgentSessionRenameRequest); + +empty_response!(RenameSessionResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "session/setArchived", response = SetSessionArchivedResponse)] +pub struct SetSessionArchivedMessage(pub AgentSessionArchiveStateRequest); + +empty_response!(SetSessionArchivedResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "session/updateModel", response = UpdateSessionModelResponse)] +pub struct UpdateSessionModelMessage(pub AgentSessionModelUpdateRequest); + +empty_response!(UpdateSessionModelResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "session/updateMode", response = UpdateSessionModeResponse)] +pub struct UpdateSessionModeMessage(pub AgentSessionModeUpdateRequest); + +empty_response!(UpdateSessionModeResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "session/fork", response = ForkSessionResponse)] +pub struct ForkSessionMessage(pub AgentSessionForkRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "session/forkAtTurn", response = ForkSessionResponse)] +pub struct ForkSessionAtTurnMessage(pub AgentSessionForkAtTurnRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "session/forkBeforeTurn", response = ForkSessionResponse)] +pub struct ForkSessionBeforeTurnMessage(pub AgentSessionForkBeforeTurnRequest); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct ForkSessionResponse(pub AgentSessionForkResult); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "session/restore", response = RestoreSessionResponse)] +#[serde(rename_all = "camelCase")] +pub struct RestoreSessionMessage { + pub workspace_path: String, + pub session_id: String, + #[serde(default)] + pub include_internal: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +impl From for bitfun_agent_runtime::sdk::AgentSessionRestoreRequest { + fn from(value: RestoreSessionMessage) -> Self { + Self { + workspace_path: value.workspace_path, + session_id: value.session_id, + include_internal: value.include_internal, + remote_connection_id: value.remote_connection_id, + remote_ssh_host: value.remote_ssh_host, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct RestoreSessionResponse { + pub session: bitfun_agent_runtime::sdk::AgentSessionSummary, + pub state: SessionRuntimeState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[cfg_attr(feature = "ts", ts(rename_all = "camelCase"))] +pub enum SessionRuntimeState { + Idle, + Processing { + current_turn_id: String, + phase: SessionProcessingPhase, + }, + Error { + error: String, + recoverable: bool, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub enum SessionProcessingPhase { + Starting, + Compacting, + Thinking, + Streaming, + ToolCalling, + ToolConfirming, +} + +impl From for RestoreSessionResponse { + fn from(value: bitfun_agent_runtime::sdk::AgentSessionRestoreResult) -> Self { + Self { + session: value.session, + state: value.state.into(), + } + } +} + +impl From for SessionRuntimeState { + fn from(value: bitfun_agent_runtime::session_state::SessionState) -> Self { + use bitfun_agent_runtime::session_state::SessionState; + + match value { + SessionState::Idle => Self::Idle, + SessionState::Processing { + current_turn_id, + phase, + } => Self::Processing { + current_turn_id, + phase: phase.into(), + }, + SessionState::Error { error, recoverable } => Self::Error { error, recoverable }, + } + } +} + +impl From for SessionProcessingPhase { + fn from(value: bitfun_agent_runtime::session_state::ProcessingPhase) -> Self { + use bitfun_agent_runtime::session_state::ProcessingPhase; + + match value { + ProcessingPhase::Starting => Self::Starting, + ProcessingPhase::Compacting => Self::Compacting, + ProcessingPhase::Thinking => Self::Thinking, + ProcessingPhase::Streaming => Self::Streaming, + ProcessingPhase::ToolCalling => Self::ToolCalling, + ProcessingPhase::ToolConfirming => Self::ToolConfirming, + } + } +} + +impl From for bitfun_agent_runtime::sdk::AgentSessionRestoreResult { + fn from(value: RestoreSessionResponse) -> Self { + Self { + session: value.session, + state: value.state.into(), + } + } +} + +impl From for bitfun_agent_runtime::session_state::SessionState { + fn from(value: SessionRuntimeState) -> Self { + match value { + SessionRuntimeState::Idle => Self::Idle, + SessionRuntimeState::Processing { + current_turn_id, + phase, + } => Self::Processing { + current_turn_id, + phase: phase.into(), + }, + SessionRuntimeState::Error { error, recoverable } => Self::Error { error, recoverable }, + } + } +} + +impl From for bitfun_agent_runtime::session_state::ProcessingPhase { + fn from(value: SessionProcessingPhase) -> Self { + match value { + SessionProcessingPhase::Starting => Self::Starting, + SessionProcessingPhase::Compacting => Self::Compacting, + SessionProcessingPhase::Thinking => Self::Thinking, + SessionProcessingPhase::Streaming => Self::Streaming, + SessionProcessingPhase::ToolCalling => Self::ToolCalling, + SessionProcessingPhase::ToolConfirming => Self::ToolConfirming, + } + } +} diff --git a/src/crates/interfaces/app-server/src/server.rs b/src/crates/interfaces/app-server/src/server.rs index 9bf9dde941..812b440a69 100644 --- a/src/crates/interfaces/app-server/src/server.rs +++ b/src/crates/interfaces/app-server/src/server.rs @@ -1,74 +1,77 @@ -//! BitFun agent kernel server backed by the generic `AppServer` role. +//! BitFun app-server assembly over the generic `AppServer` role. //! -//! [`BitfunAppServer`] wires JSON-RPC handlers for the agent kernel operations -//! (create / list / delete / submit / run / cancel) to a host-injected -//! [`BitfunAppRuntime`]. It mirrors `bitfun_acp::AcpServer` but uses the custom -//! [`AppServer`] role instead of the built-in ACP `Agent` role, so it binds no -//! ACP schema and consumers register their own message types (defined in -//! [`crate::schema`]). -//! -//! Handlers offload runtime calls to background tasks via `cx.spawn` and reply -//! through `responder.respond_with_result`, the same proven pattern as the ACP -//! server. The fallback `on_receive_dispatch` returns `method_not_found` so -//! unregistered methods surface cleanly to the client. -//! -//! The dispatch fallback also recognizes external-source method names (the old -//! Server Host `routes/external_sources.rs` surface) and returns a typed -//! "not available in web mode" error so the frontend can show a clear -//! unsupported-state message instead of a generic `method_not_found`. +//! Request handlers are grouped by product domain under [`handlers`]. This +//! module owns the server lifecycle, handler integration order, transport +//! connection, and event forwarding. -use std::sync::Arc; +mod event_forwarder; +mod fallback; +mod handlers; -use agent_client_protocol::{ConnectTo, ConnectionTo, Dispatch, Error, Result}; -use bitfun_agent_runtime::sdk::PermissionRequestEvent; -use bitfun_core::service::git::GitService; -use bitfun_events::project_agentic_frontend_event; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; -/// Method-name substrings for the external-source operations that the old Server -/// Host dispatched via `routes/external_sources.rs`. Under browser-direct ACP -/// these are not yet on the app-server schema; the dispatch fallback returns a -/// typed "not available in web mode" error so the frontend gets a clear signal -/// rather than a bare `method_not_found`. -const EXTERNAL_SOURCE_METHOD_MARKERS: &[&str] = &[ - "external_source", - "external_tool", - "external_subagent", - "external_mcp", - "external_integration", -]; +use agent_client_protocol::{ConnectTo, ConnectionTo, Result}; -use crate::agent::{ - bitfun_error, config_get_error, git_service_error, runtime_call, BitfunAppRuntime, -}; +use crate::agent::BitfunAppRuntime; use crate::role::{AppClient, AppServer}; -use crate::schema::{ - CancelTurnMessage, CancelTurnResponse, ClearProjectPermissionGrantsMessage, - ClearProjectPermissionGrantsResponse, CreateSessionMessage, CreateSessionResponse, - DeleteSessionMessage, DeleteSessionResponse, FrontendEventNotification, - GetAgentProfileConfigMessage, GetAgentProfileConfigResponse, GetAgentProfileConfigsMessage, - GetAgentProfileConfigsResponse, GetConfigMessage, GetConfigResponse, GetConfigsMessage, - GetConfigsResponse, GetModelConfigsMessage, GetModelConfigsResponse, GitBranchesRequest, - GitGetBranchesMessage, GitGetBranchesResponse, GitGetStatusMessage, GitGetStatusResponse, - GitIsRepositoryMessage, GitIsRepositoryResponse, GitRepositoryPathRequest, - ListPendingPermissionRequestsMessage, ListPendingPermissionRequestsResponse, - ListProjectPermissionAuditMessage, ListProjectPermissionAuditResponse, - ListProjectPermissionGrantsMessage, ListProjectPermissionGrantsResponse, ListSessionsMessage, - ListSessionsResponse, RemoveProjectPermissionGrantMessage, - RemoveProjectPermissionGrantResponse, RespondPermissionBatchMessage, - RespondPermissionBatchResponse, RespondPermissionMessage, RespondPermissionResponse, - RunMessage, RunResponse, SetConfigMessage, SetConfigResponse, - SubmitDialogTurnMessage, SubmitDialogTurnResponse, SubmitTurnMessage, SubmitTurnResponse, - I18nGetCurrentLanguageMessage, I18nGetCurrentLanguageResponse, I18nGetConfigMessage, - I18nGetConfigResponse, I18nGetSupportedLanguagesMessage, I18nGetSupportedLanguagesResponse, - I18nLocaleMetadata, I18nSetConfigMessage, I18nSetConfigResponse, I18nSetLanguageMessage, - I18nSetLanguageResponse, -}; + +static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1); + +pub(super) struct ConnectionEventState { + id: String, + agent_sequence: AtomicU64, + permission_sequence: AtomicU64, + config_sequence: AtomicU64, +} + +impl ConnectionEventState { + fn new() -> Self { + Self { + id: format!( + "app-server-{}", + NEXT_CONNECTION_ID.fetch_add(1, Ordering::Relaxed) + ), + agent_sequence: AtomicU64::new(0), + permission_sequence: AtomicU64::new(0), + config_sequence: AtomicU64::new(0), + } + } + + pub(super) fn cursor( + &self, + stream: bitfun_app_server_protocol::event::EventStream, + ) -> bitfun_app_server_protocol::event::EventCursor { + let sequence = match stream { + bitfun_app_server_protocol::event::EventStream::Agent => &self.agent_sequence, + bitfun_app_server_protocol::event::EventStream::Permission => &self.permission_sequence, + bitfun_app_server_protocol::event::EventStream::Config => &self.config_sequence, + }; + bitfun_app_server_protocol::event::EventCursor { + connection_id: self.id.clone(), + stream, + sequence: sequence.load(Ordering::Acquire), + } + } + + pub(super) fn next_cursor( + &self, + stream: bitfun_app_server_protocol::event::EventStream, + ) -> bitfun_app_server_protocol::event::EventCursor { + let sequence = match stream { + bitfun_app_server_protocol::event::EventStream::Agent => &self.agent_sequence, + bitfun_app_server_protocol::event::EventStream::Permission => &self.permission_sequence, + bitfun_app_server_protocol::event::EventStream::Config => &self.config_sequence, + }; + bitfun_app_server_protocol::event::EventCursor { + connection_id: self.id.clone(), + stream, + sequence: sequence.fetch_add(1, Ordering::AcqRel) + 1, + } + } +} /// BitFun agent kernel server over the generic app-server role. -/// -/// Holds a shared [`BitfunAppRuntime`]. Clone is cheap (Arc clone), so a host -/// can build one server and `serve` it on multiple transports, or keep a clone -/// around to spawn event-forwarding tasks. #[derive(Clone)] pub struct BitfunAppServer { runtime: Arc, @@ -81,652 +84,30 @@ impl BitfunAppServer { } } - /// Shared runtime handle, for callers that want to spawn side tasks such - /// as an event-forwarding loop on the same runtime. + /// Return the shared runtime used by this server. pub fn runtime(&self) -> &BitfunAppRuntime { &self.runtime } - /// Serve the agent kernel surface on a transport. The transport must - /// implement `ConnectTo` (for example the - /// [`crate::transport::in_memory_channel_pair`] server half, or `ByteStreams`). + /// 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 event_state = Arc::new(ConnectionEventState::new()); AppServer .builder() .name("bitfun-app-server") - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: CreateSessionMessage, responder, _cx| { - responder.respond_with_result(runtime_call( - runtime - .runtime() - .create_session(request.0) - .await - .map(CreateSessionResponse), - )) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: ListSessionsMessage, responder, _cx| { - let sessions = - runtime_call(runtime.runtime().list_sessions(request.0).await)?; - responder.respond(ListSessionsResponse { sessions }) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: DeleteSessionMessage, responder, _cx| { - runtime_call(runtime.runtime().delete_session(request.0).await)?; - responder.respond(DeleteSessionResponse {}) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: SubmitTurnMessage, responder, _cx| { - let session_id = request.0.session_id.clone(); - let result = runtime - .runtime() - .submit_turn(request.0) - .await - .map(SubmitTurnResponse) - .map_err(|err| { - BitfunAppRuntime::session_runtime_error(&session_id, err) - }); - responder.respond_with_result(result) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: SubmitDialogTurnMessage, responder, _cx| { - let session_id = request.0.session_id.clone(); - let result = runtime - .runtime() - .submit_dialog_turn(request.0.to_request()) - .await - .map(SubmitDialogTurnResponse::from_outcome) - .map_err(|err| { - BitfunAppRuntime::session_runtime_error(&session_id, err) - }); - responder.respond_with_result(result) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: RunMessage, responder, _cx| { - let run_request = request.to_run_request(); - let handle = runtime_call(runtime.runtime().run(run_request).await)?; - responder.respond(RunResponse::from_handle(handle)) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: CancelTurnMessage, responder, _cx| { - responder.respond_with_result(runtime_call( - runtime - .runtime() - .cancel_turn(request.0) - .await - .map(CancelTurnResponse), - )) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: RespondPermissionMessage, responder, _cx| { - runtime_call( - runtime - .runtime() - .respond_permission(&request.request_id, request.reply) - .await, - )?; - responder.respond(RespondPermissionResponse {}) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: RespondPermissionBatchMessage, responder, _cx| { - let request_ids = runtime_call( - runtime - .runtime() - .respond_permission_batch(&request.request_id, request.reply) - .await, - )?; - responder.respond(RespondPermissionBatchResponse { request_ids }) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |_request: ListPendingPermissionRequestsMessage, - responder, - _cx| { - let requests = - runtime_call(runtime.runtime().pending_permission_requests())?; - responder.respond(ListPendingPermissionRequestsResponse { requests }) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: ListProjectPermissionGrantsMessage, - responder, - _cx| { - let grants = runtime_call( - runtime - .runtime() - .list_project_permission_grants(&request.project_id) - .await, - )?; - responder.respond(ListProjectPermissionGrantsResponse { grants }) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: RemoveProjectPermissionGrantMessage, - responder, - _cx| { - let removed = runtime_call( - runtime - .runtime() - .remove_project_permission_grant(request.0) - .await, - )?; - responder.respond(RemoveProjectPermissionGrantResponse { removed }) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: ClearProjectPermissionGrantsMessage, - responder, - _cx| { - let cleared = runtime_call( - runtime - .runtime() - .clear_project_permission_grants(&request.project_id) - .await, - )?; - responder.respond(ClearProjectPermissionGrantsResponse { cleared }) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: ListProjectPermissionAuditMessage, - responder, - _cx| { - let records = runtime_call( - runtime - .runtime() - .list_project_permission_audit(&request.project_id) - .await, - )?; - responder.respond(ListProjectPermissionAuditResponse { records }) - } - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - async move |request: GitIsRepositoryMessage, responder, _cx| { - let GitRepositoryPathRequest { repository_path } = request.0; - let result = GitService::is_repository(&repository_path) - .await - .map(GitIsRepositoryResponse) - .map_err(git_service_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - async move |request: GitGetStatusMessage, responder, _cx| { - let GitRepositoryPathRequest { repository_path } = request.0; - let result = GitService::get_status(&repository_path) - .await - .map(GitGetStatusResponse) - .map_err(git_service_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - async move |request: GitGetBranchesMessage, responder, _cx| { - let GitBranchesRequest { - repository_path, - include_remote, - } = request.0; - let include_remote = include_remote.unwrap_or(false); - let result = GitService::get_branches(&repository_path, include_remote) - .await - .map(|branches| GitGetBranchesResponse { branches }) - .map_err(git_service_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - // Config service: read-only agent-profile and model-config reads. - // The handlers reach the global config singletons the Desktop host - // also uses -- `mode_config_canonicalizer::get_agent_profile_*` - // (static) and `get_global_config_service` (`config_service - // .get_ai_models`) -- so no injection is needed, mirroring the - // static `GitService` pattern. - .on_receive_request( - async move |_request: GetAgentProfileConfigsMessage, responder, _cx| { - let result = - bitfun_core::service::config::mode_config_canonicalizer::get_agent_profile_views() - .await - .map(|profiles| GetAgentProfileConfigsResponse { profiles }) - .map_err(bitfun_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - async move |request: GetAgentProfileConfigMessage, responder, _cx| { - let result = - bitfun_core::service::config::mode_config_canonicalizer::get_agent_profile_view( - &request.agent_id, - ) - .await - .map(GetAgentProfileConfigResponse) - .map_err(bitfun_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - async move |_request: GetModelConfigsMessage, responder, _cx| { - let result = async { - let config_service = - bitfun_core::service::config::get_global_config_service().await?; - config_service.get_ai_models().await - } - .await - .map(|models| GetModelConfigsResponse { models }) - .map_err(bitfun_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - // `config/getConfig` / `config/getConfigs` -- single + batched - // config-path reads. `ConfigService::get_config::(path)` - // returns the raw JSON tree at that path; a missing path errors with - // `BitFunError::NotFound("Config path '' not found")`. The - // `config_get_error` mapper puts that Display text into the JSON-RPC - // `message` (not just `data`) so the frontend `ConfigAPI.getConfig` - // substring match (`not found:` + `config path` + `''`) hits - // and swallows the error into `undefined` the same way it does on - // desktop. `skipRetryOnNotFound` rides along unbranched -- the - // app-server does not retry; the field steers frontend retry policy - // and desktop logging. - .on_receive_request( - async move |request: GetConfigMessage, responder, _cx| { - log::debug!("server getConfig request: {:?}", request); - let result = async { - let config_service = - bitfun_core::service::config::get_global_config_service().await?; - config_service - .get_config::(request.path.as_deref()) - .await - } - .await - .map(GetConfigResponse) - .map_err(config_get_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - async move |request: GetConfigsMessage, responder, _cx| { - let result = async { - let config_service = - bitfun_core::service::config::get_global_config_service().await?; - // Dedupe paths the same way the desktop host does - // (`config_api.rs::get_configs` skips a path already - // seen), preserving first-seen order in the map. - let mut configs = std::collections::BTreeMap::new(); - for path in request.paths { - if configs.contains_key(&path) { - continue; - } - let value = config_service - .get_config::(Some(path.as_str())) - .await?; - configs.insert(path, value); - } - Ok(configs) - } - .await - .map(|configs| GetConfigsResponse { configs }) - .map_err(config_get_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - // `config/setConfig` (Track B): write a value at a config path via - // the global config singleton -- the same accessor the Desktop host - // uses (`config_api.rs::set_config`). No service injection. - .on_receive_request( - async move |request: SetConfigMessage, responder, _cx| { - let result = async { - let config_service = - bitfun_core::service::config::get_global_config_service().await?; - config_service - .set_config::(&request.path, request.value) - .await - } - .await - .map(|()| SetConfigResponse {}) - .map_err(bitfun_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - // I18n service surface (Track B): read/write the runtime locale via - // the global config singleton (`app.language`) + the global - // I18nService (`sync_global_i18n_service_locale`). Locale ids are - // validated against `LocaleId::from_str`; an unsupported id surfaces - // as an `invalid_request`-style error. - .on_receive_request( - async move |_request: I18nGetCurrentLanguageMessage, responder, _cx| { - let result = async { - let config_service = - bitfun_core::service::config::get_global_config_service().await?; - let lang: String = config_service - .get_config::(Some("app.language")) - .await - .unwrap_or_else(|_| "zh-CN".to_string()); - Ok::<_, bitfun_core::BitFunError>(lang) - } - .await - .map(|language| I18nGetCurrentLanguageResponse { language }) - .map_err(bitfun_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - async move |request: I18nSetLanguageMessage, responder, _cx| { - let result = async { - let locale_id = - bitfun_core::service::i18n::LocaleId::from_str(&request.language) - .ok_or_else(|| { - bitfun_core::BitFunError::validation(format!( - "Unsupported language: {}", - request.language - )) - })?; - let config_service = - bitfun_core::service::config::get_global_config_service().await?; - config_service - .set_config("app.language", locale_id.as_str()) - .await?; - // Sync the global I18nService; a non-initialized - // service logs but is not fatal (matches the host - // dispatcher's behavior). - let _ = bitfun_core::service::i18n::sync_global_i18n_service_locale( - locale_id, - ) - .await; - Ok::<_, bitfun_core::BitFunError>(locale_id.as_str().to_string()) - } - .await - .map(|language| I18nSetLanguageResponse { language }) - .map_err(bitfun_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - async move |_request: I18nGetConfigMessage, responder, _cx| { - let result = async { - let config_service = - bitfun_core::service::config::get_global_config_service().await?; - let current_language = config_service - .get_config::(Some("app.language")) - .await - .unwrap_or_else(|_| "zh-CN".to_string()); - Ok::<_, bitfun_core::BitFunError>(I18nGetConfigResponse { - current_language, - fallback_language: "en-US".to_string(), - auto_detect: false, - }) - } - .await - .map_err(bitfun_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - async move |request: I18nSetConfigMessage, responder, _cx| { - let result = async { - if let Some(language) = request.current_language.as_deref() { - let locale_id = - bitfun_core::service::i18n::LocaleId::from_str(language) - .ok_or_else(|| { - bitfun_core::BitFunError::validation(format!( - "Unsupported language: {}", - language - )) - })?; - let config_service = - bitfun_core::service::config::get_global_config_service().await?; - config_service - .set_config("app.language", locale_id.as_str()) - .await?; - let _ = bitfun_core::service::i18n::sync_global_i18n_service_locale( - locale_id, - ) - .await; - } - Ok::<_, bitfun_core::BitFunError>(()) - } - .await - .map(|()| I18nSetConfigResponse {}) - .map_err(bitfun_error); - responder.respond_with_result(result) - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - async move |_request: I18nGetSupportedLanguagesMessage, responder, _cx| { - let locales = bitfun_core::service::i18n::LocaleMetadata::all() - .into_iter() - .map(|locale| I18nLocaleMetadata { - id: locale.id.as_str().to_string(), - name: locale.name, - english_name: locale.english_name, - native_name: locale.native_name, - rtl: locale.rtl, - }) - .collect(); - responder.respond(I18nGetSupportedLanguagesResponse { locales }) - }, - agent_client_protocol::on_receive_request!(), - ) - .on_receive_dispatch( - async move |message: Dispatch, cx: ConnectionTo| { - // Extract the method name so external-source commands get a - // typed "not available in web mode" error instead of a - // bare method_not_found. - let method = match &message { - Dispatch::Request(req, _) => req.method().to_string(), - _ => String::new(), - }; - let is_external_source = EXTERNAL_SOURCE_METHOD_MARKERS - .iter() - .any(|marker| method.contains(marker)); - let error = if is_external_source { - Error::method_not_found().data(serde_json::json!({ - "capability": "external_sources", - "reason": "not_available_in_web_mode", - "message": "External source operations are not yet available in web mode. Use the desktop host." - })) - } else { - Error::method_not_found() - }; - message.respond_with_error(error, cx) - }, - agent_client_protocol::on_receive_dispatch!(), - ) - // Drive the connection with a `main_fn` instead of `connect_to` so the - // server can forward runtime events to the client as `agent/event` - // notifications. This loop runs concurrently with the request handlers - // above and parks the connection for its lifetime; `connect_with` - // cancels it when the transport closes (same lifecycle as the - // `connect_to` pending-main pattern). + .with_connection_builder(handlers::app::builder(runtime.clone(), event_state.clone())) + .with_connection_builder(handlers::agent::builder(runtime.clone())) + .with_connection_builder(handlers::session::builder(runtime.clone())) + .with_connection_builder(handlers::permission::builder(runtime.clone())) + .with_connection_builder(handlers::tui::builder(runtime.clone())) + .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| { - let mut rx = runtime.event_source().subscribe(); - // The permission receiver carries the same lifecycle stream the - // desktop host emits as `permission://event`; forward each event - // as an `agent/permissionEvent` notification so the client never - // subscribes to the runtime permission stream directly. If the - // runtime has no permission manager the subscription fails -- the - // permission commands still work, this connection just receives - // no permission push, so we drain runtime events only. - let mut permission_rx = runtime - .runtime() - .subscribe_permission_requests() - .ok(); - loop { - let permission_recv = async { - match &mut permission_rx { - Some(receiver) => Some(receiver.recv().await), - // No permission stream available: park forever so - // this select! arm never fires. - None => std::future::pending::< - Option< - Result< - PermissionRequestEvent, - tokio::sync::broadcast::error::RecvError, - >, - >, - >() - .await, - } - }; - tokio::select! { - recv = rx.recv() => match recv { - Ok(envelope) => { - // Project the runtime event to the frontend - // (`agentic://`) shape the browser listens on - // today, and push it as a `agent/frontendEvent` - // notification. The browser's WS adapter dispatches on - // `params.event`, so its existing `listen(...)` call - // sites stay unchanged under browser-direct ACP. - if let Some(projected) = - project_agentic_frontend_event(envelope.event) - { - let notification = FrontendEventNotification { - event: projected.event_name, - payload: projected.payload, - }; - if let Err(error) = cx.send_notification(notification) { - log::warn!( - "App-server event forwarder failed to send a notification: {:?} -- skipping this event", - error - ); - continue; - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(missed)) => { - log::warn!( - "App-server event forwarder lagged behind the runtime queue: {} events missed", - missed - ); - continue; - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - log::warn!( - "App-server event forwarder stream closed -- serve main loop exiting (client RPCs will now fail with 'receiver is gone')" - ); - break; - } - }, - recv = permission_recv => match recv { - Some(Ok(event)) => { - // Project the permission lifecycle event to the - // `permission://event` channel the browser listens on - // (same name as the desktop host's - // `app.emit("permission://event")`), and push it as a - // `agent/frontendEvent` notification. The payload is the - // serialized `PermissionRequestEvent` (the same shape - // `client.rs` projected to before Step 2). - if let Ok(payload) = serde_json::to_value(&event) { - let notification = FrontendEventNotification { - event: "permission://event".to_string(), - payload, - }; - if let Err(error) = cx.send_notification(notification) { - log::warn!( - "App-server permission event forwarder failed to send a notification: {:?} -- skipping this event", - error - ); - continue; - } - } - } - Some(Err( - tokio::sync::broadcast::error::RecvError::Lagged(missed), - )) => { - log::warn!( - "App-server permission event forwarder lagged: {} events missed", - missed - ); - continue; - } - // Closed: drop the permission stream but keep - // forwarding runtime events for the connection's life. - Some(Err( - tokio::sync::broadcast::error::RecvError::Closed, - )) => { - permission_rx = None; - } - None => {} - }, - } - } - Ok(()) + event_forwarder::run(runtime, cx, event_state).await }) .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 new file mode 100644 index 0000000000..ec9caf02f1 --- /dev/null +++ b/src/crates/interfaces/app-server/src/server/event_forwarder.rs @@ -0,0 +1,141 @@ +use crate::agent::BitfunAppRuntime; +use crate::role::AppClient; +use crate::schema::{ + config_update_from_owner, ConfigEventNotification, EventStream, EventStreamState, + EventStreamStateNotification, PermissionEventNotification, ResyncDirective, + SessionEventNotification, +}; +use agent_client_protocol::{ConnectionTo, Result}; +use bitfun_agent_runtime::sdk::PermissionRequestEvent; +use std::sync::Arc; + +pub(super) async fn run( + runtime: Arc, + 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(); + loop { + let permission_recv = async { + match &mut permission_rx { + Some(receiver) => Some(receiver.recv().await), + None => { + std::future::pending::< + Option< + Result< + PermissionRequestEvent, + tokio::sync::broadcast::error::RecvError, + >, + >, + >() + .await + } + } + }; + let config_recv = async { + match &mut config_rx { + Some(receiver) => Some(receiver.recv().await), + None => { + std::future::pending::< + Option< + Result< + bitfun_core::service::config::ConfigUpdateEvent, + tokio::sync::broadcast::error::RecvError, + >, + >, + >() + .await + } + } + }; + tokio::select! { + recv = rx.recv() => match recv { + Ok(envelope) => { + let notification = SessionEventNotification { + cursor: event_state.next_cursor(EventStream::Agent), + event: envelope, + }; + if let Err(error) = cx.send_notification(notification) { + log::warn!("App-server agent event forwarder failed to send a notification: {:?} -- skipping this event", error); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(missed)) => { + send_stream_state(&cx, &event_state, EventStream::Agent, EventStreamState::Lagged, Some(missed), "session/sync", false); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + send_stream_state(&cx, &event_state, EventStream::Agent, EventStreamState::Closed, None, "session/sync", false); + log::warn!("App-server agent event stream closed -- serve main loop exiting (client RPCs will now fail with 'receiver is gone')"); + break; + } + }, + 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) { + log::warn!("App-server permission 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::Permission, EventStreamState::Lagged, Some(missed), "app/syncEvents", true); + } + Some(Err(tokio::sync::broadcast::error::RecvError::Closed)) => { + send_stream_state(&cx, &event_state, EventStream::Permission, EventStreamState::Closed, None, "app/syncEvents", true); + permission_rx = None; + } + None => {} + }, + recv = config_recv => match recv { + Some(Ok(event)) => { + if let Err(error) = cx.send_notification(ConfigEventNotification { + cursor: event_state.next_cursor(EventStream::Config), + event: config_update_from_owner(event), + }) { + log::warn!("App-server config 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::Config, EventStreamState::Lagged, Some(missed), "app/syncEvents", false); + } + Some(Err(tokio::sync::broadcast::error::RecvError::Closed)) => { + send_stream_state(&cx, &event_state, EventStream::Config, EventStreamState::Closed, None, "app/syncEvents", false); + config_rx = None; + } + None => {} + } + } + } + Ok(()) +} + +fn send_stream_state( + cx: &ConnectionTo, + event_state: &crate::server::ConnectionEventState, + stream: EventStream, + state: EventStreamState, + missed: Option, + method: &str, + snapshot_available: bool, +) { + let notification = EventStreamStateNotification { + cursor: event_state.cursor(stream), + stream, + state, + missed, + resync: ResyncDirective { + method: method.to_string(), + snapshot_available, + reason: Some("The authoritative event stream is no longer contiguous".to_string()), + }, + }; + if let Err(error) = cx.send_notification(notification) { + log::warn!( + "App-server event stream state notification failed: {:?}", + error + ); + } +} diff --git a/src/crates/interfaces/app-server/src/server/fallback.rs b/src/crates/interfaces/app-server/src/server/fallback.rs new file mode 100644 index 0000000000..0244fadc50 --- /dev/null +++ b/src/crates/interfaces/app-server/src/server/fallback.rs @@ -0,0 +1,38 @@ +use crate::role::{AppClient, AppServer}; +use agent_client_protocol::{Builder, ConnectionTo, Dispatch, Error, HandleDispatchFrom}; + +const EXTERNAL_SOURCE_METHOD_MARKERS: &[&str] = &[ + "external_source", + "external_tool", + "external_subagent", + "external_mcp", + "external_integration", +]; + +pub(super) fn builder() -> Builder> { + AppServer + .builder() + .name("dispatch fallback") + .on_receive_dispatch( + async move |message: Dispatch, cx: ConnectionTo| { + let method = match &message { + Dispatch::Request(request, _) => request.method().to_string(), + _ => String::new(), + }; + let error = if EXTERNAL_SOURCE_METHOD_MARKERS + .iter() + .any(|marker| method.contains(marker)) + { + Error::method_not_found().data(serde_json::json!({ + "capability": "external_sources", + "reason": "not_available_in_web_mode", + "message": "External source operations are not yet available in web mode. Use the desktop host." + })) + } else { + Error::method_not_found() + }; + message.respond_with_error(error, cx) + }, + agent_client_protocol::on_receive_dispatch!(), + ) +} diff --git a/src/crates/interfaces/app-server/src/server/handlers/agent.rs b/src/crates/interfaces/app-server/src/server/handlers/agent.rs new file mode 100644 index 0000000000..371695529e --- /dev/null +++ b/src/crates/interfaces/app-server/src/server/handlers/agent.rs @@ -0,0 +1,111 @@ +use crate::agent::{runtime_call, BitfunAppRuntime}; +use crate::role::{AppClient, AppServer}; +use crate::schema::*; +use agent_client_protocol::{Builder, HandleDispatchFrom}; +use std::sync::Arc; + +pub(in crate::server) fn builder( + runtime: Arc, +) -> Builder> { + AppServer + .builder() + .name("agent handlers") + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: CreateSessionMessage, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .create_session(request.0) + .await + .map(CreateSessionResponse), + )) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: ListSessionsMessage, responder, _cx| { + let sessions = runtime_call(runtime.runtime().list_sessions(request.0).await)?; + responder.respond(ListSessionsResponse { sessions }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: DeleteSessionMessage, responder, _cx| { + runtime_call(runtime.runtime().delete_session(request.0).await)?; + responder.respond(DeleteSessionResponse {}) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SubmitTurnMessage, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .submit_turn(request.0) + .await + .map(SubmitTurnResponse) + .map_err(|err| { + BitfunAppRuntime::session_runtime_error(&session_id, err) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SubmitDialogTurnMessage, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .submit_dialog_turn(request.0.to_request()) + .await + .map(SubmitDialogTurnResponse::from_outcome) + .map_err(|err| { + BitfunAppRuntime::session_runtime_error(&session_id, err) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: RunMessage, responder, _cx| { + let handle = + runtime_call(runtime.runtime().run(request.to_run_request()).await)?; + responder.respond(RunResponse::from_handle(handle)) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + async move |request: CancelTurnMessage, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .cancel_turn(request.0) + .await + .map(CancelTurnResponse), + )) + } + }, + agent_client_protocol::on_receive_request!(), + ) +} diff --git a/src/crates/interfaces/app-server/src/server/handlers/app.rs b/src/crates/interfaces/app-server/src/server/handlers/app.rs new file mode 100644 index 0000000000..7cbead8b40 --- /dev/null +++ b/src/crates/interfaces/app-server/src/server/handlers/app.rs @@ -0,0 +1,184 @@ +use agent_client_protocol::{Builder, Error, HandleDispatchFrom}; +use bitfun_app_server_protocol::app::{ + CapabilityAvailability, CapabilityDescriptor, HealthRequest, HealthResponse, HealthStatus, + InitializeRequest, InitializeResponse, ServerInfo, TransportLimits, +}; +use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; +use bitfun_app_server_protocol::event::{SyncEventsRequest, SyncEventsResponse}; +use bitfun_app_server_protocol::{MIN_PROTOCOL_VERSION, PROTOCOL_VERSION}; + +use crate::role::{AppClient, AppServer}; + +const MAX_FRAME_BYTES: u64 = 16 * 1024 * 1024; +const EVENT_BUFFER_CAPACITY: u32 = 1024; + +pub(in crate::server) fn builder( + runtime: std::sync::Arc, + event_state: std::sync::Arc, +) -> Builder> { + AppServer + .builder() + .name("app lifecycle handlers") + .on_receive_request( + async move |request: InitializeRequest, responder, _cx| { + 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), + ))); + } + responder.respond_with_result(Ok(InitializeResponse::new( + ServerInfo { + name: "bitfun-app-server".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + registered_capabilities(), + TransportLimits { + max_frame_bytes: MAX_FRAME_BYTES, + event_buffer_capacity: EVENT_BUFFER_CAPACITY, + }, + ))) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_: HealthRequest, responder, _cx| { + responder.respond(HealthResponse { + status: HealthStatus::Ready, + protocol_version: PROTOCOL_VERSION, + }) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: SyncEventsRequest, responder, _cx| { + let pending_permissions = runtime + .runtime() + .pending_permission_requests() + .unwrap_or_default(); + responder.respond(SyncEventsResponse { + cursors: request + .streams + .into_iter() + .map(|stream| event_state.cursor(stream)) + .collect(), + pending_permissions, + agent_snapshot_available: false, + config_snapshot_available: false, + }) + }, + agent_client_protocol::on_receive_request!(), + ) +} + +fn registered_capabilities() -> Vec { + [ + ( + "agent", + vec![ + "agent/createSession", + "agent/listSessions", + "agent/deleteSession", + "agent/submitTurn", + "agent/submitDialogTurn", + "agent/steerTurn", + "agent/runUserShellCommand", + "agent/submitUserAnswers", + "agent/cancelTurn", + "agent/run", + "agent/event", + ], + ), + ( + "session", + vec![ + "session/sync", + "session/readTranscript", + "session/resolveWorkspace", + "session/recordLocalCommandTurn", + "session/rename", + "session/setArchived", + "session/updateModel", + "session/updateMode", + "session/fork", + "session/forkAtTurn", + "session/forkBeforeTurn", + "session/restore", + "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/respondPermissionBatch", + "agent/listPendingPermissionRequests", + "agent/listProjectPermissionGrants", + "agent/removeProjectPermissionGrant", + "agent/clearProjectPermissionGrants", + "agent/listProjectPermissionAudit", + ], + ), + ( + "workspace", + vec![ + "workspace/diff", + "workspace/searchReferences", + "workspace/messageReferences", + ], + ), + ( + "git", + vec!["git/isRepository", "git/getStatus", "git/getBranches"], + ), + ( + "config", + vec![ + "config/event", + "config/getAgentProfileConfigs", + "config/getAgentProfileConfig", + "config/getModelConfigs", + "config/getConfig", + "config/getConfigs", + "config/setConfig", + "config/setAgentProfileConfig", + "config/resetAgentProfileConfig", + ], + ), + ( + "i18n", + vec![ + "i18n/getCurrentLanguage", + "i18n/setLanguage", + "i18n/getConfig", + "i18n/setConfig", + "i18n/getSupportedLanguages", + ], + ), + ("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(), + }) + .collect() +} diff --git a/src/crates/interfaces/app-server/src/server/handlers/config.rs b/src/crates/interfaces/app-server/src/server/handlers/config.rs new file mode 100644 index 0000000000..e3aa7115e9 --- /dev/null +++ b/src/crates/interfaces/app-server/src/server/handlers/config.rs @@ -0,0 +1,136 @@ +use crate::agent::{bitfun_error, config_get_error}; +use crate::role::{AppClient, AppServer}; +use crate::schema::*; +use agent_client_protocol::{Builder, HandleDispatchFrom}; + +pub(in crate::server) fn builder() -> Builder> { + AppServer + .builder() + .name("config handlers") + .on_receive_request( + async move |_: GetAgentProfileConfigsMessage, responder, _cx| { + let result = bitfun_core::service::config::mode_config_canonicalizer::get_agent_profile_views() + .await + .map(|profiles| GetAgentProfileConfigsResponse { profiles }) + .map_err(bitfun_error); + responder.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: GetAgentProfileConfigMessage, responder, _cx| { + let result = bitfun_core::service::config::mode_config_canonicalizer::get_agent_profile_view(&request.agent_id) + .await + .map(GetAgentProfileConfigResponse) + .map_err(bitfun_error); + responder.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_: GetModelConfigsMessage, responder, _cx| { + let result = async { + let service = bitfun_core::service::config::get_global_config_service().await?; + service.get_ai_models().await + } + .await + .map(|models| GetModelConfigsResponse { models }) + .map_err(bitfun_error); + responder.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: GetConfigMessage, responder, _cx| { + log::debug!("server getConfig request: {:?}", request); + let result = async { + let service = bitfun_core::service::config::get_global_config_service().await?; + service + .get_config::(request.path.as_deref()) + .await + } + .await + .map(GetConfigResponse) + .map_err(config_get_error); + responder.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: GetConfigsMessage, responder, _cx| { + let result = async { + let service = bitfun_core::service::config::get_global_config_service().await?; + let mut configs = std::collections::BTreeMap::new(); + for path in request.paths { + if configs.contains_key(&path) { + continue; + } + let value = service + .get_config::(Some(path.as_str())) + .await?; + configs.insert(path, value); + } + Ok(configs) + } + .await + .map(|configs| GetConfigsResponse { configs }) + .map_err(config_get_error); + responder.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: SetConfigMessage, responder, _cx| { + let result = async { + let service = bitfun_core::service::config::get_global_config_service().await?; + service + .set_config::(&request.path, request.value) + .await + } + .await + .map(|()| SetConfigResponse {}) + .map_err(bitfun_error); + responder.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: SetAgentProfileConfigMessage, responder, _cx| { + let result = async { + bitfun_core::service::config::mode_config_canonicalizer::persist_agent_profile_from_value( + &request.agent_id, + request.config, + ) + .await?; + bitfun_core::service::config::mode_config_canonicalizer::get_agent_profile_view( + &request.agent_id, + ) + .await + } + .await + .map(SetAgentProfileConfigResponse) + .map_err(bitfun_error); + responder.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: ResetAgentProfileConfigMessage, responder, _cx| { + let result = async { + bitfun_core::service::config::mode_config_canonicalizer::reset_agent_profile_to_default( + &request.agent_id, + ) + .await?; + bitfun_core::service::config::mode_config_canonicalizer::get_agent_profile_view( + &request.agent_id, + ) + .await + } + .await + .map(ResetAgentProfileConfigResponse) + .map_err(bitfun_error); + responder.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) +} diff --git a/src/crates/interfaces/app-server/src/server/handlers/git.rs b/src/crates/interfaces/app-server/src/server/handlers/git.rs new file mode 100644 index 0000000000..769fd437a5 --- /dev/null +++ b/src/crates/interfaces/app-server/src/server/handlers/git.rs @@ -0,0 +1,55 @@ +use agent_client_protocol::{Builder, HandleDispatchFrom}; +use bitfun_core::service::git::GitService; + +use crate::agent::git_service_error; +use crate::role::{AppClient, AppServer}; +use crate::schema::{ + GitBranchesRequest, GitGetBranchesMessage, GitGetBranchesResponse, GitGetStatusMessage, + GitGetStatusResponse, GitIsRepositoryMessage, GitIsRepositoryResponse, + GitRepositoryPathRequest, +}; + +pub(in crate::server) fn builder() -> Builder> { + AppServer + .builder() + .name("git handlers") + .on_receive_request( + async move |request: GitIsRepositoryMessage, responder, _cx| { + let GitRepositoryPathRequest { repository_path } = request.0; + responder.respond_with_result( + GitService::is_repository(&repository_path) + .await + .map(GitIsRepositoryResponse) + .map_err(git_service_error), + ) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: GitGetStatusMessage, responder, _cx| { + let GitRepositoryPathRequest { repository_path } = request.0; + responder.respond_with_result( + GitService::get_status(&repository_path) + .await + .map(GitGetStatusResponse) + .map_err(git_service_error), + ) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: GitGetBranchesMessage, responder, _cx| { + let GitBranchesRequest { + repository_path, + include_remote, + } = request.0; + let result = + GitService::get_branches(&repository_path, include_remote.unwrap_or(false)) + .await + .map(|branches| GitGetBranchesResponse { branches }) + .map_err(git_service_error); + responder.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) +} diff --git a/src/crates/interfaces/app-server/src/server/handlers/i18n.rs b/src/crates/interfaces/app-server/src/server/handlers/i18n.rs new file mode 100644 index 0000000000..38c14777b4 --- /dev/null +++ b/src/crates/interfaces/app-server/src/server/handlers/i18n.rs @@ -0,0 +1,114 @@ +use crate::agent::bitfun_error; +use crate::role::{AppClient, AppServer}; +use crate::schema::*; +use agent_client_protocol::{Builder, HandleDispatchFrom}; + +pub(in crate::server) fn builder() -> Builder> { + AppServer + .builder() + .name("i18n handlers") + .on_receive_request( + async move |_: I18nGetCurrentLanguageMessage, p, _| { + let result = async { + let s = bitfun_core::service::config::get_global_config_service().await?; + Ok::<_, bitfun_core::BitFunError>( + s.get_config::(Some("app.language")) + .await + .unwrap_or_else(|_| "zh-CN".to_string()), + ) + } + .await + .map(|language| I18nGetCurrentLanguageResponse { language }) + .map_err(bitfun_error); + p.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |r: I18nSetLanguageMessage, p, _| { + let result = async { + let locale = bitfun_core::service::i18n::LocaleId::from_str(&r.language) + .ok_or_else(|| { + bitfun_core::BitFunError::validation(format!( + "Unsupported language: {}", + r.language + )) + })?; + bitfun_core::service::config::get_global_config_service() + .await? + .set_config("app.language", locale.as_str()) + .await?; + let _ = + bitfun_core::service::i18n::sync_global_i18n_service_locale(locale).await; + Ok::<_, bitfun_core::BitFunError>(locale.as_str().to_string()) + } + .await + .map(|language| I18nSetLanguageResponse { language }) + .map_err(bitfun_error); + p.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_: I18nGetConfigMessage, p, _| { + let result = async { + let s = bitfun_core::service::config::get_global_config_service().await?; + Ok::<_, bitfun_core::BitFunError>(I18nGetConfigResponse { + current_language: s + .get_config::(Some("app.language")) + .await + .unwrap_or_else(|_| "zh-CN".to_string()), + fallback_language: "en-US".to_string(), + auto_detect: false, + }) + } + .await + .map_err(bitfun_error); + p.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |r: I18nSetConfigMessage, p, _| { + let result = async { + if let Some(language) = r.current_language.as_deref() { + let locale = bitfun_core::service::i18n::LocaleId::from_str(language) + .ok_or_else(|| { + bitfun_core::BitFunError::validation(format!( + "Unsupported language: {}", + language + )) + })?; + bitfun_core::service::config::get_global_config_service() + .await? + .set_config("app.language", locale.as_str()) + .await?; + let _ = bitfun_core::service::i18n::sync_global_i18n_service_locale(locale) + .await; + } + Ok::<_, bitfun_core::BitFunError>(()) + } + .await + .map(|()| I18nSetConfigResponse {}) + .map_err(bitfun_error); + p.respond_with_result(result) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_: I18nGetSupportedLanguagesMessage, p, _| { + let locales = bitfun_core::service::i18n::LocaleMetadata::all() + .into_iter() + .map(|locale| I18nLocaleMetadata { + id: locale.id.as_str().to_string(), + name: locale.name, + english_name: locale.english_name, + native_name: locale.native_name, + rtl: locale.rtl, + }) + .collect(); + p.respond(I18nGetSupportedLanguagesResponse { locales }) + }, + agent_client_protocol::on_receive_request!(), + ) +} diff --git a/src/crates/interfaces/app-server/src/server/handlers/mod.rs b/src/crates/interfaces/app-server/src/server/handlers/mod.rs new file mode 100644 index 0000000000..5ef878b2a9 --- /dev/null +++ b/src/crates/interfaces/app-server/src/server/handlers/mod.rs @@ -0,0 +1,10 @@ +//! Domain-grouped JSON-RPC request handlers. + +pub(in crate::server) mod agent; +pub(in crate::server) mod app; +pub(in crate::server) mod config; +pub(in crate::server) mod git; +pub(in crate::server) mod i18n; +pub(in crate::server) mod permission; +pub(in crate::server) mod session; +pub(in crate::server) mod tui; diff --git a/src/crates/interfaces/app-server/src/server/handlers/permission.rs b/src/crates/interfaces/app-server/src/server/handlers/permission.rs new file mode 100644 index 0000000000..874358fc0b --- /dev/null +++ b/src/crates/interfaces/app-server/src/server/handlers/permission.rs @@ -0,0 +1,108 @@ +use crate::agent::{runtime_call, BitfunAppRuntime}; +use crate::role::{AppClient, AppServer}; +use crate::schema::*; +use agent_client_protocol::{Builder, HandleDispatchFrom}; +use std::sync::Arc; + +pub(in crate::server) fn builder( + runtime: Arc, +) -> Builder> { + AppServer + .builder() + .name("permission handlers") + .on_receive_request( + { + let runtime = runtime.clone(); + async move |r: RespondPermissionMessage, p, _| { + runtime_call( + runtime + .runtime() + .respond_permission(&r.request_id, r.reply) + .await, + )?; + p.respond(RespondPermissionResponse {}) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |r: RespondPermissionBatchMessage, p, _| { + let request_ids = runtime_call( + runtime + .runtime() + .respond_permission_batch(&r.request_id, r.reply) + .await, + )?; + p.respond(RespondPermissionBatchResponse { request_ids }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |_: ListPendingPermissionRequestsMessage, p, _| { + let requests = runtime_call(runtime.runtime().pending_permission_requests())?; + p.respond(ListPendingPermissionRequestsResponse { requests }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |r: ListProjectPermissionGrantsMessage, p, _| { + let grants = runtime_call( + runtime + .runtime() + .list_project_permission_grants(&r.project_id) + .await, + )?; + p.respond(ListProjectPermissionGrantsResponse { grants }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |r: RemoveProjectPermissionGrantMessage, p, _| { + let removed = + runtime_call(runtime.runtime().remove_project_permission_grant(r.0).await)?; + p.respond(RemoveProjectPermissionGrantResponse { removed }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |r: ClearProjectPermissionGrantsMessage, p, _| { + let cleared = runtime_call( + runtime + .runtime() + .clear_project_permission_grants(&r.project_id) + .await, + )?; + p.respond(ClearProjectPermissionGrantsResponse { cleared }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + async move |r: ListProjectPermissionAuditMessage, p, _| { + let records = runtime_call( + runtime + .runtime() + .list_project_permission_audit(&r.project_id) + .await, + )?; + p.respond(ListProjectPermissionAuditResponse { records }) + } + }, + 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 new file mode 100644 index 0000000000..ac23de4382 --- /dev/null +++ b/src/crates/interfaces/app-server/src/server/handlers/session.rs @@ -0,0 +1,152 @@ +use std::sync::Arc; + +use agent_client_protocol::{Builder, HandleDispatchFrom}; + +use crate::agent::{runtime_call, BitfunAppRuntime}; +use crate::role::{AppClient, AppServer}; +use crate::schema::*; + +pub(in crate::server) fn builder( + runtime: Arc, +) -> Builder> { + AppServer + .builder() + .name("session handlers") + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: RenameSessionMessage, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .rename_session(request.0) + .await + .map(|()| RenameSessionResponse {}) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SetSessionArchivedMessage, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .set_session_archived(request.0) + .await + .map(|()| SetSessionArchivedResponse {}) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: UpdateSessionModelMessage, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .update_session_model(request.0) + .await + .map(|()| UpdateSessionModelResponse {}) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: UpdateSessionModeMessage, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .update_session_mode(request.0) + .await + .map(|()| UpdateSessionModeResponse {}) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: ForkSessionMessage, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .fork_session(request.0) + .await + .map(ForkSessionResponse), + )) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: ForkSessionAtTurnMessage, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .fork_session_at_turn(request.0) + .await + .map(ForkSessionResponse), + )) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: ForkSessionBeforeTurnMessage, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .fork_session_before_turn(request.0) + .await + .map(ForkSessionResponse), + )) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: RestoreSessionMessage, responder, _cx| { + let session_id = request.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .restore_session(request.into()) + .await + .map(RestoreSessionResponse::from) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + }, + agent_client_protocol::on_receive_request!(), + ) +} diff --git a/src/crates/interfaces/app-server/src/server/handlers/tui.rs b/src/crates/interfaces/app-server/src/server/handlers/tui.rs new file mode 100644 index 0000000000..427312be39 --- /dev/null +++ b/src/crates/interfaces/app-server/src/server/handlers/tui.rs @@ -0,0 +1,424 @@ +use std::sync::Arc; + +use agent_client_protocol::{Builder, Error, HandleDispatchFrom}; +use bitfun_agent_runtime::sdk::{ + AgentSessionRestoreRequest, AgentUserAnswersRequest, DialogSteerOutcome, ProcessingPhase, + SessionState, +}; +use bitfun_app_server_protocol::tui::*; +use bitfun_runtime_ports::{AgentSessionWorkspaceBinding, SessionExecutionTarget}; + +use crate::agent::{runtime_call, BitfunAppRuntime}; +use crate::role::{AppClient, AppServer}; + +pub(in crate::server) fn builder( + runtime: Arc, +) -> Builder> { + AppServer + .builder() + .name("tui core handlers") + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SyncSessionRequest, responder, _cx| { + let session_id = request.session_id.clone(); + let workspace_path = request.workspace_path.clone(); + let restored = runtime + .runtime() + .restore_session(AgentSessionRestoreRequest { + workspace_path: request.workspace_path, + session_id: request.session_id, + include_internal: request.include_internal, + remote_connection_id: request.remote_connection_id, + remote_ssh_host: request.remote_ssh_host, + }) + .await + .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() + .resolve_session_workspace_binding( + bitfun_runtime_ports::AgentSessionWorkspaceRequest { + session_id: session_id.clone(), + }, + ) + .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(); + + responder.respond(SyncSessionResponse { + session: restored.session, + state: session_state(restored.state), + transcript, + workspace_binding, + pending_permissions, + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: RecordLocalCommandTurnRequest, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .record_completed_local_command_turn(request.0) + .await + .map(RecordLocalCommandTurnResponse) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: ReadTranscriptRequest, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .read_session_transcript(request.0) + .await + .map(ReadTranscriptResponse), + )) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: ResolveWorkspaceRequest, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .resolve_session_workspace_binding(request.0) + .await + .map(ResolveWorkspaceResponse), + )) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SteerTurnRequest, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .steer_dialog_turn(request.0) + .await + .map(|outcome| match outcome { + DialogSteerOutcome::Buffered { steering_id, .. } => { + SteerTurnResponse { steering_id } + } + }) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: RunUserShellCommandRequest, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .run_user_shell_command(request.0) + .await + .map(RunUserShellCommandResponse) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SubmitUserAnswersRequest, responder, _cx| { + runtime_call( + runtime + .runtime() + .submit_user_answers(AgentUserAnswersRequest { + tool_id: request.tool_id, + answers: request.answers, + }) + .await, + )?; + responder.respond(SubmitUserAnswersResponse {}) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: CompactSessionRequest, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .start_session_compaction(request.0) + .await + .map(CompactSessionResponse) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: UndoSessionRequest, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .undo_session(request.0) + .await + .map(RevertSessionResponse) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: RedoSessionRequest, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .redo_session(request.0) + .await + .map(RevertSessionResponse) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + 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 {}) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SessionUsageRequest, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .generate_session_usage(request.0) + .await + .map(SessionUsageResponse) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: WaitForSettlementRequest, responder, _cx| { + let session_id = request.0.session_id.clone(); + responder.respond_with_result( + runtime + .runtime() + .wait_for_turn_settlement(request.0) + .await + .map(|()| WaitForSettlementResponse {}) + .map_err(|error| { + BitfunAppRuntime::session_runtime_error(&session_id, error) + }), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |_: WorkspaceDiffRequest, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .workspace_diff() + .await + .map(WorkspaceDiffResponse), + )) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SearchWorkspaceReferencesRequest, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .search_workspace_references(request.0) + .await + .map(SearchWorkspaceReferencesResponse), + )) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: MessageReferencesRequest, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .workspace_references_for_message(request.0) + .await + .map(MessageReferencesResponse), + )) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SessionLineageRequest, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .get_session_lineage(request.0) + .await + .map(SessionLineageResponse), + )) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: InspectLineageRequest, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .read_lineage_session_transcript(request.0) + .await + .map(InspectLineageResponse), + )) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: CancelLineageRequest, responder, _cx| { + responder.respond_with_result(runtime_call( + runtime + .runtime() + .cancel_lineage_session(request.0) + .await + .map(CancelLineageResponse), + )) + }, + agent_client_protocol::on_receive_request!(), + ) +} + +fn fallback_workspace_binding(workspace_path: String) -> AgentSessionWorkspaceBinding { + AgentSessionWorkspaceBinding { + workspace_id: None, + workspace_path: workspace_path.clone(), + project_workspace_path: Some(workspace_path.clone()), + execution_target: Some(SessionExecutionTarget::local(workspace_path)), + remote_connection_id: None, + remote_ssh_host: None, + } +} + +fn session_state(state: SessionState) -> SessionRuntimeState { + match state { + SessionState::Idle => SessionRuntimeState::Idle, + SessionState::Processing { + current_turn_id, + phase, + } => SessionRuntimeState::Processing { + current_turn_id, + phase: processing_phase(phase), + }, + SessionState::Error { error, recoverable } => { + SessionRuntimeState::Error { error, recoverable } + } + } +} + +fn processing_phase(phase: ProcessingPhase) -> SessionProcessingPhase { + match phase { + ProcessingPhase::Starting => SessionProcessingPhase::Starting, + ProcessingPhase::Compacting => SessionProcessingPhase::Compacting, + ProcessingPhase::Thinking => SessionProcessingPhase::Thinking, + ProcessingPhase::Streaming => SessionProcessingPhase::Streaming, + ProcessingPhase::ToolCalling => SessionProcessingPhase::ToolCalling, + ProcessingPhase::ToolConfirming => SessionProcessingPhase::ToolConfirming, + } +} diff --git a/src/crates/interfaces/app-server/src/transport.rs b/src/crates/interfaces/app-server/src/transport.rs index ba0827440c..26ebc3ac64 100644 --- a/src/crates/interfaces/app-server/src/transport.rs +++ b/src/crates/interfaces/app-server/src/transport.rs @@ -1,21 +1,3 @@ -//! Transport helpers for wiring an in-process app-server connection. +//! Compatibility re-export of behavior-light transport helpers. -use agent_client_protocol::Channel; - -/// Build a paired in-process server/client transport over two `mpsc` channels. -/// -/// Returns `(server_channel, client_channel)`, a connected pair of -/// [`Channel`]s from [`Channel::duplex`]. The pair moves typed -/// [`jsonrpcmsg::Message`](agent_client_protocol) values directly between the -/// two endpoints -- no `serde_json::to_string`/`from_str` on the wire, only -/// the typed-request ↔ `Message::Request(params: Value)` value conversion -/// (`to_value`/`from_value`, which is value conversion, not serialization). -/// -/// `Channel` implements [`agent_client_protocol::ConnectTo`] for any role, so -/// either endpoint can be passed to [`crate::BitfunAppServer::serve`] or an -/// `AppClient` builder directly. For a byte-stream boundary (stdio, sockets, -/// ...) the caller should construct an `agent_client_protocol::ByteStreams`/ -/// `Lines` transport directly; this helper is for same-process Rust pairs. -pub fn in_memory_channel_pair() -> (Channel, Channel) { - Channel::duplex() -} +pub use bitfun_app_server_protocol::transport::in_memory_channel_pair; diff --git a/src/crates/interfaces/app-server/tests/agent_kernel.rs b/src/crates/interfaces/app-server/tests/agent_kernel.rs index d7da4580a1..971474228e 100644 --- a/src/crates/interfaces/app-server/tests/agent_kernel.rs +++ b/src/crates/interfaces/app-server/tests/agent_kernel.rs @@ -18,22 +18,37 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; -use agent_client_protocol::{ConnectionTo, SentRequest}; +use agent_client_protocol::{ConnectionTo, ErrorCode, SentRequest}; use async_trait::async_trait; use bitfun_agent_runtime::event_queue::{EventQueue, EventQueueConfig}; use bitfun_agent_runtime::sdk::{ - AgentEventSource, AgentEventStream, AgentRuntimeBuilder, AgentSessionCreateRequest, - AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionListRequest, - AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, - AgentTurnCancellationRequest, AgenticEvent, PortResult, + AgentEventSource, AgentEventStream, AgentRuntimeBuilder, AgentSessionArchiveStateRequest, + AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionDeleteRequest, + AgentSessionForkAtTurnRequest, AgentSessionForkPort, AgentSessionForkRequest, + AgentSessionForkResult, AgentSessionListRequest, AgentSessionManagementPort, + AgentSessionModePort, AgentSessionModeUpdateRequest, AgentSessionModelPort, + AgentSessionModelUpdateRequest, AgentSessionRenameRequest, AgentSessionRestorePort, + AgentSessionRestoreRequest, AgentSessionRestoreResult, AgentSessionSummary, + AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionPort, + AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, + AgentTurnCancellationRequest, AgenticEvent, PortResult, ProcessingPhase, SessionState, }; use bitfun_app_server::schema::{ CancelTurnMessage, CreateSessionMessage, CreateSessionResponse, DeleteSessionMessage, - FrontendEventNotification, ListSessionsMessage, RespondPermissionMessage, RunMessage, - RunResponse, RunSessionSpec, SubmitDialogTurnBody, SubmitDialogTurnMessage, - SubmitDialogTurnResponse, SubmitTurnMessage, SubmitTurnResponse, + ForkSessionAtTurnMessage, ForkSessionResponse, ListSessionsMessage, RenameSessionMessage, + RenameSessionResponse, RespondPermissionMessage, RestoreSessionMessage, RunMessage, + RunResponse, RunSessionSpec, SessionRuntimeState, SetSessionArchivedMessage, + SetSessionArchivedResponse, SubmitDialogTurnBody, SubmitDialogTurnMessage, + SubmitDialogTurnResponse, SubmitTurnMessage, SubmitTurnResponse, UpdateSessionModeMessage, + UpdateSessionModeResponse, UpdateSessionModelMessage, UpdateSessionModelResponse, }; use bitfun_app_server::{transport, AppClient, AppServer, BitfunAppRuntime, BitfunAppServer}; +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::tui; +use bitfun_app_server_protocol::PROTOCOL_VERSION; +use bitfun_runtime_ports as ports; use tokio::task::LocalSet; /// Minimal `AgentSubmissionPort` mock modeled on `sdk_minimal.rs`. @@ -126,6 +141,874 @@ fn build_app_runtime_with_queue() -> (BitfunAppRuntime, Arc) { ) } +#[derive(Debug, Default)] +struct SessionControlProvider { + renamed: Mutex>, + archive_updates: Mutex>, + model_updates: Mutex>, + mode_updates: Mutex>, + forks_at_turn: Mutex>, + restores: Mutex>, +} + +#[async_trait] +impl AgentSessionManagementPort for SessionControlProvider { + 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<()> { + self.renamed.lock().unwrap().push(request); + Ok(()) + } + + async fn set_session_archived( + &self, + request: AgentSessionArchiveStateRequest, + ) -> PortResult<()> { + self.archive_updates.lock().unwrap().push(request); + Ok(()) + } + + async fn resolve_session_workspace_binding( + &self, + _request: AgentSessionWorkspaceRequest, + ) -> PortResult> { + Ok(None) + } +} + +#[async_trait] +impl AgentSessionModelPort for SessionControlProvider { + async fn update_session_model( + &self, + request: AgentSessionModelUpdateRequest, + ) -> PortResult<()> { + self.model_updates.lock().unwrap().push(request); + Ok(()) + } +} + +#[async_trait] +impl AgentSessionModePort for SessionControlProvider { + async fn update_session_mode(&self, request: AgentSessionModeUpdateRequest) -> PortResult<()> { + self.mode_updates.lock().unwrap().push(request); + Ok(()) + } +} + +#[async_trait] +impl AgentSessionForkPort for SessionControlProvider { + async fn fork_session( + &self, + request: AgentSessionForkRequest, + ) -> PortResult { + Ok(AgentSessionForkResult { + session_id: format!("{}-fork", request.source_session_id), + session_name: "Forked Session".to_string(), + agent_type: "agentic".to_string(), + }) + } + + async fn fork_session_at_turn( + &self, + request: AgentSessionForkAtTurnRequest, + ) -> PortResult { + self.forks_at_turn.lock().unwrap().push(request); + Ok(AgentSessionForkResult { + session_id: "forked-session".to_string(), + session_name: "Forked at Turn".to_string(), + agent_type: "agentic".to_string(), + }) + } +} + +#[async_trait] +impl AgentSessionRestorePort for SessionControlProvider { + async fn restore_session( + &self, + request: AgentSessionRestoreRequest, + ) -> PortResult { + self.restores.lock().unwrap().push(request); + Ok(AgentSessionRestoreResult { + session: AgentSessionSummary { + session_id: "session-1".to_string(), + session_name: "Restored Session".to_string(), + agent_type: "agentic".to_string(), + model_id: Some("provider/model".to_string()), + last_user_dialog_agent_type: None, + last_submitted_agent_type: Some("agentic".to_string()), + turn_count: 4, + created_at_ms: 10, + last_active_at_ms: 20, + }, + state: SessionState::Processing { + current_turn_id: "turn-active".to_string(), + phase: ProcessingPhase::Thinking, + }, + }) + } +} + +fn build_session_control_app_runtime() -> (BitfunAppRuntime, Arc) { + let submission = Arc::new(ExampleAgentProvider::default()); + let session_control = Arc::new(SessionControlProvider::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(submission.clone()) + .with_dialog_turn_port(submission) + .with_session_management_port(session_control.clone()) + .with_session_model_port(session_control.clone()) + .with_session_mode_port(session_control.clone()) + .with_session_fork_port(session_control.clone()) + .with_session_restore_port(session_control.clone()) + .with_event_stream(AgentEventStream::new()) + .build() + .expect("runtime should build with Session control ports"); + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + ( + BitfunAppRuntime::new(runtime, AgentEventSource::new(event_queue)), + session_control, + ) +} + +#[derive(Debug, Default)] +struct Phase2Provider { + steers: Mutex>, + shell_commands: Mutex>, + answers: Mutex>, + local_commands: Mutex>, + compactions: Mutex>, + settlements: Mutex>, + reloads: Mutex>, +} + +#[async_trait] +impl AgentSubmissionPort for Phase2Provider { + async fn create_session( + &self, + request: AgentSessionCreateRequest, + ) -> PortResult { + Ok(AgentSessionCreateResult::new( + "phase2-session", + request.session_name, + request.agent_type, + )) + } + + async fn submit_message( + &self, + request: AgentSubmissionRequest, + ) -> PortResult { + Ok(AgentSubmissionResult { + turn_id: request.turn_id.unwrap_or_else(|| "phase2-turn".to_string()), + accepted: true, + }) + } + + async fn resolve_session_agent_type(&self, _session_id: &str) -> PortResult> { + Ok(Some("agentic".to_string())) + } +} + +#[async_trait] +impl ports::AgentSessionManagementPort for Phase2Provider { + async fn list_sessions( + &self, + _request: ports::AgentSessionListRequest, + ) -> PortResult> { + Ok(Vec::new()) + } + + async fn delete_session(&self, _request: ports::AgentSessionDeleteRequest) -> PortResult<()> { + Ok(()) + } + + async fn rename_session(&self, _request: ports::AgentSessionRenameRequest) -> PortResult<()> { + Ok(()) + } + + async fn resolve_session_workspace_binding( + &self, + _request: ports::AgentSessionWorkspaceRequest, + ) -> PortResult> { + Ok(Some(ports::AgentSessionWorkspaceBinding { + workspace_id: Some("workspace-1".to_string()), + workspace_path: "/authoritative/workspace".to_string(), + project_workspace_path: Some("/authoritative/workspace".to_string()), + execution_target: Some(ports::SessionExecutionTarget::local( + "/authoritative/workspace", + )), + remote_connection_id: None, + remote_ssh_host: None, + })) + } +} + +#[async_trait] +impl bitfun_agent_runtime::sdk::AgentSessionRestorePort for Phase2Provider { + async fn restore_session( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionRestoreRequest, + ) -> PortResult { + Ok(bitfun_agent_runtime::sdk::AgentSessionRestoreResult { + session: ports::AgentSessionSummary { + session_id: request.session_id, + session_name: "Phase 2".to_string(), + agent_type: "agentic".to_string(), + model_id: Some("provider/model".to_string()), + 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-active".to_string(), + phase: ProcessingPhase::Streaming, + }, + }) + } +} + +#[async_trait] +impl ports::SessionTranscriptReader for Phase2Provider { + async fn read_session_transcript( + &self, + request: ports::SessionTranscriptRequest, + ) -> PortResult { + Ok(ports::SessionTranscript { + session_id: request.session_id, + 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()), + }], + }) + } +} + +#[async_trait] +impl ports::AgentDialogTurnPort for Phase2Provider { + async fn submit_dialog_turn( + &self, + request: ports::AgentDialogTurnRequest, + ) -> PortResult { + Ok(ports::DialogSubmitOutcome::Started { + session_id: request.session_id, + turn_id: request.turn_id.unwrap_or_else(|| "turn-new".to_string()), + }) + } + + async fn steer_dialog_turn( + &self, + request: ports::AgentDialogSteerRequest, + ) -> PortResult { + self.steers.lock().unwrap().push(request.clone()); + Ok(ports::DialogSteerOutcome::Buffered { + session_id: request.session_id, + turn_id: request.turn_id, + steering_id: "steering-1".to_string(), + }) + } +} + +#[async_trait] +impl ports::AgentUserShellCommandPort for Phase2Provider { + async fn run_user_shell_command( + &self, + request: ports::AgentUserShellCommandRequest, + ) -> PortResult { + self.shell_commands.lock().unwrap().push(request.clone()); + Ok(ports::AgentUserShellCommandResult { + session_id: request.session_id, + turn_id: request.turn_id, + }) + } +} + +#[async_trait] +impl ports::AgentInteractionResponsePort for Phase2Provider { + async fn submit_user_answers(&self, request: ports::AgentUserAnswersRequest) -> PortResult<()> { + self.answers.lock().unwrap().push(request); + Ok(()) + } +} + +#[async_trait] +impl ports::AgentLocalCommandTurnPort for Phase2Provider { + async fn record_completed_local_command_turn( + &self, + request: ports::AgentLocalCommandTurnRecordRequest, + ) -> PortResult { + self.local_commands.lock().unwrap().push(request.clone()); + Ok(ports::AgentLocalCommandTurnRecordResult { + turn_id: request + .turn_id + .unwrap_or_else(|| "local-command-1".to_string()), + storage_turn_index: 1, + }) + } +} + +#[async_trait] +impl ports::AgentSessionCompactionPort for Phase2Provider { + async fn start_session_compaction( + &self, + request: ports::AgentSessionCompactionRequest, + ) -> PortResult { + self.compactions.lock().unwrap().push(request.clone()); + Ok(ports::AgentSessionCompactionResult { + session_id: request.session_id, + turn_id: request.turn_id, + }) + } +} + +#[async_trait] +impl ports::AgentSessionRevertPort for Phase2Provider { + async fn undo_session( + &self, + request: ports::AgentSessionRevertRequest, + ) -> PortResult { + Ok(revert_result(request.session_id, "undo restored")) + } + + async fn redo_session( + &self, + request: ports::AgentSessionRevertRequest, + ) -> PortResult { + Ok(revert_result(request.session_id, "redo restored")) + } +} + +#[async_trait] +impl ports::AgentSessionUsagePort for Phase2Provider { + async fn generate_session_usage( + &self, + request: ports::AgentSessionUsageRequest, + ) -> PortResult { + Ok( + bitfun_agent_runtime::sdk::SessionUsageReport::partial_unavailable( + request.session_id, + 1_778_347_200_000, + ), + ) + } +} + +#[async_trait] +impl ports::AgentTurnSettlementPort for Phase2Provider { + async fn wait_for_turn_settlement( + &self, + request: ports::AgentTurnSettlementRequest, + ) -> PortResult<()> { + self.settlements.lock().unwrap().push(request); + Ok(()) + } +} + +#[async_trait] +impl ports::AgentWorkspaceReferencePort for Phase2Provider { + async fn search_workspace_references( + &self, + _request: ports::AgentWorkspaceReferenceSearchRequest, + ) -> PortResult { + Ok(ports::AgentWorkspaceReferenceSearchResult { + entries: vec![ports::AgentWorkspaceReferenceSearchEntry { + path: "src/lib.rs".to_string(), + kind: ports::AgentWorkspaceReferenceKind::File, + }], + truncated: false, + }) + } + + async fn workspace_references_for_message( + &self, + _request: ports::AgentMessageWorkspaceReferencesRequest, + ) -> PortResult> { + Ok(vec![ports::AgentWorkspaceReference { + path: "src/lib.rs".to_string(), + kind: ports::AgentWorkspaceReferenceKind::File, + start_line: Some(1), + end_line: Some(2), + source: ports::AgentWorkspaceReferenceSourceRange { + start: 0, + end: 11, + value: "@src/lib.rs".to_string(), + }, + }]) + } +} + +#[async_trait] +impl ports::AgentSessionLineagePort for Phase2Provider { + async fn get_session_lineage( + &self, + _request: ports::AgentSessionLineageRequest, + ) -> PortResult> { + Ok(Some(ports::AgentSessionLineageSnapshot { + root_session_id: "session-1".to_string(), + sessions: vec![ports::AgentSessionLineageEntry { + session_id: "session-1".to_string(), + session_name: "Root".to_string(), + agent_type: "agentic".to_string(), + created_at_ms: 10, + status: ports::AgentSessionLifecycleStatus::Active, + active_turn_id: Some("turn-active".to_string()), + parent_session_id: None, + parent_tool_call_id: None, + subagent_type: None, + workspace_path: Some("/authoritative/workspace".to_string()), + remote_connection_id: None, + remote_ssh_host: None, + unread_completion: None, + needs_user_attention: None, + }], + })) + } + + async fn read_lineage_session_transcript( + &self, + request: ports::AgentSessionLineageTranscriptRequest, + ) -> PortResult { + Ok(ports::AgentSessionLineageInspection { + transcript: ports::SessionTranscript { + session_id: request.session_id, + messages: Vec::new(), + }, + active_turn_id: Some("turn-active".to_string()), + }) + } + + async fn cancel_lineage_session( + &self, + request: ports::AgentSessionLineageCancellationRequest, + ) -> PortResult { + Ok(ports::AgentTurnCancellationResult { + session_id: request.session_id, + turn_id: request.expected_active_turn_id, + requested: true, + }) + } +} + +#[async_trait] +impl ports::AgentContextReloadPort for Phase2Provider { + async fn reload_session_context( + &self, + request: ports::AgentContextReloadRequest, + ) -> PortResult<()> { + self.reloads.lock().unwrap().push(request); + Ok(()) + } +} + +fn revert_result(session_id: String, text: &str) -> ports::AgentSessionRevertResult { + ports::AgentSessionRevertResult { + session_id: session_id.clone(), + transcript: ports::SessionTranscript { + session_id, + messages: vec![ports::TranscriptMessage { + id: Some("message-reverted".to_string()), + role: "user".to_string(), + turn_id: None, + timestamp_ms: None, + content: ports::TranscriptContent::Text(text.to_string()), + }], + }, + composer: ports::AgentSessionComposerUpdate::Replace { + text: text.to_string(), + }, + retired_turn_ids: vec!["turn-active".to_string()], + changed: true, + hidden_turn_count: 1, + } +} + +#[derive(Debug)] +struct TestRuntimeService(ports::RuntimeServiceCapability); + +impl ports::RuntimeServicePort for TestRuntimeService { + fn capability(&self) -> ports::RuntimeServiceCapability { + self.0 + } +} + +impl ports::FileSystemPort for TestRuntimeService {} +impl ports::WorkspacePort for TestRuntimeService {} + +#[async_trait] +impl ports::SessionStorePort for TestRuntimeService { + async fn resolve_session_storage_path( + &self, + request: ports::SessionStoragePathRequest, + ) -> PortResult { + Ok(ports::SessionStoragePathResolution::new( + request.workspace_path.clone(), + request.workspace_path, + ports::SessionStorageKind::Local, + request.remote_connection_id, + request.remote_ssh_host, + )) + } +} + +impl ports::ClockPort for TestRuntimeService { + fn now_unix_millis(&self) -> i64 { + 1_778_347_200_000 + } +} + +#[async_trait] +impl ports::GitPort for TestRuntimeService { + async fn workspace_diff(&self) -> PortResult { + Ok(ports::WorkspaceDiffSnapshot { + files: vec![ports::WorkspaceDiffFile { + path: "src/lib.rs".to_string(), + old_path: None, + status: ports::WorkspaceDiffFileStatus::Modified, + staged: false, + unstaged: true, + untracked: false, + additions: 1, + deletions: 1, + content: ports::WorkspaceDiffContent::Text { + patch: "@@ -1 +1 @@\n-old\n+new\n".to_string(), + }, + }], + truncated: false, + }) + } +} + +#[derive(Debug)] +struct TestRuntimeEventSink; + +#[async_trait] +impl ports::RuntimeEventSink for TestRuntimeEventSink { + async fn publish_runtime_event(&self, _event: ports::RuntimeEventEnvelope) -> PortResult<()> { + Ok(()) + } +} + +fn build_phase2_app_runtime() -> (BitfunAppRuntime, Arc) { + let provider = Arc::new(Phase2Provider::default()); + let services = bitfun_agent_runtime::sdk::RuntimeServicesBuilder::new() + .with_filesystem(Arc::new(TestRuntimeService( + ports::RuntimeServiceCapability::FileSystem, + ))) + .with_workspace(Arc::new(TestRuntimeService( + ports::RuntimeServiceCapability::Workspace, + ))) + .with_session_store(Arc::new(TestRuntimeService( + ports::RuntimeServiceCapability::SessionStore, + ))) + .with_events(Arc::new(TestRuntimeEventSink)) + .with_clock(Arc::new(TestRuntimeService( + ports::RuntimeServiceCapability::Clock, + ))) + .with_optional_git(Some(Arc::new(TestRuntimeService( + ports::RuntimeServiceCapability::Git, + )))) + .build() + .expect("phase 2 runtime services"); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(provider.clone()) + .with_session_management_port(provider.clone()) + .with_session_restore_port(provider.clone()) + .with_session_transcript_reader(provider.clone()) + .with_dialog_turn_port(provider.clone()) + .with_interaction_response_port(provider.clone()) + .with_local_command_turn_port(provider.clone()) + .with_user_shell_command_port(provider.clone()) + .with_session_compaction_port(provider.clone()) + .with_session_revert_port(provider.clone()) + .with_session_usage_port(provider.clone()) + .with_turn_settlement_port(provider.clone()) + .with_workspace_reference_port(provider.clone()) + .with_session_lineage_port(provider.clone()) + .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()), + provider, + ) +} + +#[tokio::test(flavor = "current_thread")] +async fn phase2_sync_aggregates_authoritative_session_state() { + 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(); + spawn_server(runtime, server_transport); + + let client = bitfun_app_server_client::connect(client_transport) + .await + .expect("connect app server client"); + let response = client + .sync_session(tui::SyncSessionRequest { + workspace_path: "/requested/workspace".to_string(), + session_id: "session-1".to_string(), + include_internal: true, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .expect("sync session"); + + assert_eq!(response.session.session_name, "Phase 2"); + assert!(matches!( + response.state, + tui::SessionRuntimeState::Processing { + ref current_turn_id, + ref phase, + } if current_turn_id == "turn-active" + && matches!(phase, tui::SessionProcessingPhase::Streaming) + )); + assert_eq!(response.transcript.messages.len(), 1); + assert_eq!(response.transcript.session_id, "session-1"); + assert_eq!( + response.workspace_binding.workspace_id.as_deref(), + Some("workspace-1") + ); + assert_eq!( + response.workspace_binding.workspace_path, + "/authoritative/workspace" + ); + client.shutdown().await; + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn phase2_mutations_route_through_runtime_owner_ports() { + 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(); + spawn_server(runtime, server_transport); + + let client = bitfun_app_server_client::connect(client_transport) + .await + .expect("connect app server client"); + let steer = client + .steer_turn(tui::SteerTurnRequest(ports::AgentDialogSteerRequest { + session_id: "session-1".to_string(), + turn_id: "turn-active".to_string(), + content: "keep going".to_string(), + display_content: None, + })) + .await + .expect("steer turn"); + assert_eq!(steer.steering_id, "steering-1"); + + let shell = client + .run_user_shell_command(tui::RunUserShellCommandRequest( + ports::AgentUserShellCommandRequest { + session_id: "session-1".to_string(), + turn_id: "shell-turn".to_string(), + command: "cargo test".to_string(), + }, + )) + .await + .expect("run shell command"); + assert_eq!(shell.0.turn_id, "shell-turn"); + + client + .submit_user_answers(tui::SubmitUserAnswersRequest { + tool_id: "ask-1".to_string(), + answers: serde_json::json!({"answer": "yes"}), + }) + .await + .expect("submit user answers"); + let local_turn = client + .record_local_command_turn(tui::RecordLocalCommandTurnRequest( + ports::AgentLocalCommandTurnRecordRequest { + session_id: "session-1".to_string(), + content: "usage: 12 tokens".to_string(), + turn_id: Some("local-turn".to_string()), + timestamp_ms: Some(100), + metadata: serde_json::Map::new(), + }, + )) + .await + .expect("record local command turn"); + assert_eq!(local_turn.0.turn_id, "local-turn"); + + client + .compact_session(tui::CompactSessionRequest( + ports::AgentSessionCompactionRequest { + session_id: "session-1".to_string(), + turn_id: "compact-turn".to_string(), + }, + )) + .await + .expect("compact session"); + let undone = client + .undo_session(tui::UndoSessionRequest(ports::AgentSessionRevertRequest { + workspace_path: "/workspace".to_string(), + session_id: "session-1".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + })) + .await + .expect("undo session"); + assert!(undone.0.changed); + client + .redo_session(tui::RedoSessionRequest(ports::AgentSessionRevertRequest { + workspace_path: "/workspace".to_string(), + session_id: "session-1".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + })) + .await + .expect("redo session"); + client + .reload_context(tui::ReloadContextRequest( + ports::AgentContextReloadRequest { + session_id: "session-1".to_string(), + target: ports::AgentContextReloadTarget::All, + }, + )) + .await + .expect("reload context"); + + assert_eq!(provider.steers.lock().unwrap().len(), 1); + assert_eq!( + provider.shell_commands.lock().unwrap()[0].command, + "cargo test" + ); + assert_eq!(provider.answers.lock().unwrap().len(), 1); + assert_eq!(provider.local_commands.lock().unwrap().len(), 1); + assert_eq!(provider.compactions.lock().unwrap().len(), 1); + assert_eq!(provider.reloads.lock().unwrap().len(), 1); + client.shutdown().await; + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn phase2_read_models_cover_usage_settlement_references_lineage_and_diff() { + 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(); + spawn_server(runtime, server_transport); + + let client = bitfun_app_server_client::connect(client_transport) + .await + .expect("connect app server client"); + let usage = client + .session_usage(tui::SessionUsageRequest(ports::AgentSessionUsageRequest { + session_id: "session-1".to_string(), + workspace_path: Some("/workspace".to_string()), + remote_connection_id: None, + remote_ssh_host: None, + include_hidden_subagents: false, + })) + .await + .expect("session usage"); + assert_eq!(usage.0.session_id, "session-1"); + + client + .wait_for_settlement(tui::WaitForSettlementRequest( + ports::AgentTurnSettlementRequest { + session_id: "session-1".to_string(), + turn_id: "turn-active".to_string(), + wait_timeout_ms: 1_000, + }, + )) + .await + .expect("wait for settlement"); + let search = client + .search_workspace_references(tui::SearchWorkspaceReferencesRequest( + ports::AgentWorkspaceReferenceSearchRequest { + session_id: "session-1".to_string(), + query: "lib".to_string(), + limit: 5, + }, + )) + .await + .expect("search references"); + assert_eq!(search.0.entries[0].path, "src/lib.rs"); + let references = client + .message_references(tui::MessageReferencesRequest( + ports::AgentMessageWorkspaceReferencesRequest { + session_id: "session-1".to_string(), + message_id: "message-1".to_string(), + }, + )) + .await + .expect("message references"); + assert_eq!(references.0[0].path, "src/lib.rs"); + let lineage = client + .session_lineage(tui::SessionLineageRequest( + ports::AgentSessionLineageRequest { + workspace_path: "/workspace".to_string(), + anchor_session_id: "session-1".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }, + )) + .await + .expect("lineage"); + assert_eq!(lineage.0.unwrap().root_session_id, "session-1"); + let inspection = client + .inspect_lineage(tui::InspectLineageRequest( + ports::AgentSessionLineageTranscriptRequest { + workspace_path: "/workspace".to_string(), + root_session_id: "session-1".to_string(), + session_id: "session-1".to_string(), + required_settled_turn_ids: vec!["turn-active".to_string()], + remote_connection_id: None, + remote_ssh_host: None, + }, + )) + .await + .expect("inspect lineage"); + assert_eq!(inspection.0.active_turn_id.as_deref(), Some("turn-active")); + let cancelled = client + .cancel_lineage(tui::CancelLineageRequest( + ports::AgentSessionLineageCancellationRequest { + workspace_path: "/workspace".to_string(), + root_session_id: "session-1".to_string(), + session_id: "session-1".to_string(), + expected_active_turn_id: Some("turn-active".to_string()), + source: None, + reason: Some("user".to_string()), + wait_timeout_ms: Some(1_000), + remote_connection_id: None, + remote_ssh_host: None, + }, + )) + .await + .expect("cancel lineage"); + assert!(cancelled.0.requested); + let diff = client.workspace_diff().await.expect("workspace diff"); + assert_eq!(diff.0.files[0].path, "src/lib.rs"); + assert_eq!(provider.settlements.lock().unwrap().len(), 1); + client.shutdown().await; + }) + .await; +} + async fn recv(response: SentRequest) -> Result where T: agent_client_protocol::JsonRpcResponse + Send, @@ -148,6 +1031,202 @@ fn spawn_server( }); } +#[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 client = bitfun_app_server_client::connect(client_transport) + .await + .expect("lightweight client should connect"); + let initialized = client + .initialize(InitializeRequest { + protocol_version: PROTOCOL_VERSION, + client: ClientInfo { + name: "bitfun-tui-test".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + }) + .await + .expect("initialize should negotiate the supported protocol"); + assert_eq!(initialized.protocol_version, PROTOCOL_VERSION); + assert!(initialized + .capabilities + .iter() + .any(|capability| capability.id == "session")); + let methods = initialized + .capabilities + .iter() + .flat_map(|capability| capability.methods.iter()) + .map(String::as_str) + .collect::>(); + for method in [ + "session/sync", + "agent/steerTurn", + "agent/runUserShellCommand", + "agent/submitUserAnswers", + "session/undo", + "session/redo", + "session/compact", + "session/reloadContext", + "session/usage", + "session/waitForSettlement", + "workspace/diff", + "workspace/searchReferences", + "workspace/messageReferences", + "session/lineage", + "session/inspectLineage", + "session/cancelLineage", + ] { + assert!( + methods.contains(method), + "missing advertised method {method}" + ); + } + + let health = client.health().await.expect("health should round trip"); + assert_eq!(health.status, HealthStatus::Ready); + assert_eq!(health.protocol_version, PROTOCOL_VERSION); + + let synchronized = client + .sync_events(SyncEventsRequest { + streams: vec![EventStream::Agent, EventStream::Permission], + }) + .await + .expect("event cursors should synchronize through the same connection"); + assert_eq!(synchronized.cursors.len(), 2); + assert_eq!(synchronized.cursors[0].stream, EventStream::Agent); + assert_eq!(synchronized.cursors[1].stream, EventStream::Permission); + assert_eq!( + synchronized.cursors[0].connection_id, + synchronized.cursors[1].connection_id + ); + assert!(!synchronized.agent_snapshot_available); + + let error = client + .initialize(InitializeRequest { + protocol_version: PROTOCOL_VERSION + 1, + client: ClientInfo { + name: "bitfun-tui-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")); + client.shutdown().await; + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn session_control_methods_forward_exact_owner_dtos() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let (runtime, provider) = build_session_control_app_runtime(); + spawn_server(runtime, server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let RenameSessionResponse {} = recv(cx.send_request(RenameSessionMessage( + AgentSessionRenameRequest { + workspace_path: "/repo".to_string(), + session_id: "session-1".to_string(), + session_name: "Renamed".to_string(), + remote_connection_id: Some("remote-1".to_string()), + remote_ssh_host: None, + }, + ))) + .await?; + let SetSessionArchivedResponse {} = recv(cx.send_request( + SetSessionArchivedMessage(AgentSessionArchiveStateRequest { + workspace_path: "/repo".to_string(), + session_id: "session-1".to_string(), + archived: false, + remote_connection_id: None, + remote_ssh_host: Some("host-1".to_string()), + }), + )) + .await?; + let UpdateSessionModelResponse {} = recv(cx.send_request( + UpdateSessionModelMessage(AgentSessionModelUpdateRequest { + session_id: "session-1".to_string(), + model_id: "provider/model".to_string(), + }), + )) + .await?; + let UpdateSessionModeResponse {} = recv(cx.send_request( + UpdateSessionModeMessage(AgentSessionModeUpdateRequest { + session_id: "session-1".to_string(), + mode_id: "plan".to_string(), + }), + )) + .await?; + let ForkSessionResponse(forked) = recv(cx.send_request( + ForkSessionAtTurnMessage(AgentSessionForkAtTurnRequest { + workspace_path: "/repo".to_string(), + source_session_id: "session-1".to_string(), + source_turn_id: "turn-2".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }), + )) + .await?; + assert_eq!(forked.session_id, "forked-session"); + + let restored = recv(cx.send_request(RestoreSessionMessage { + workspace_path: "/repo".to_string(), + session_id: "session-1".to_string(), + include_internal: true, + remote_connection_id: None, + remote_ssh_host: None, + })) + .await?; + assert_eq!(restored.session.session_name, "Restored Session"); + assert!(matches!( + restored.state, + SessionRuntimeState::Processing { + ref current_turn_id, + .. + } if current_turn_id == "turn-active" + )); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + + assert_eq!(provider.renamed.lock().unwrap()[0].session_name, "Renamed"); + assert!(!provider.archive_updates.lock().unwrap()[0].archived); + assert_eq!( + provider.model_updates.lock().unwrap()[0].model_id, + "provider/model" + ); + assert_eq!(provider.mode_updates.lock().unwrap()[0].mode_id, "plan"); + assert_eq!( + provider.forks_at_turn.lock().unwrap()[0].source_turn_id, + "turn-2" + ); + assert!(provider.restores.lock().unwrap()[0].include_internal); + }) + .await; +} + #[tokio::test(flavor = "current_thread")] async fn run_round_trips_through_create_and_submit() { let local = LocalSet::new(); @@ -481,7 +1560,7 @@ 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>> = + let received: Arc>> = Arc::new(Mutex::new(Vec::new())); let received_for_client = received.clone(); let queue_for_client = event_queue.clone(); @@ -491,7 +1570,7 @@ async fn runtime_events_are_forwarded_as_agent_event_notifications() { .on_receive_notification( { let received = received_for_client.clone(); - async move |notification: FrontendEventNotification, + async move |notification: AgentEventNotification, _cx: ConnectionTo| { received.lock().unwrap().push(notification); Ok(()) @@ -523,18 +1602,15 @@ async fn runtime_events_are_forwarded_as_agent_event_notifications() { assert_eq!( received.len(), 1, - "should receive exactly one projected frontend event, got {received:?}" - ); - // Step 2: the server now projects the runtime event to the frontend - // shape (`agentic://`) before pushing, so the client sees a - // `FrontendEventNotification` rather than the raw envelope. - assert_eq!(received[0].event, "agentic://session-state-changed"); - assert_eq!( - received[0].payload["sessionId"].as_str(), - Some("s1"), - "projected payload should carry the session id: {:?}", - received[0].payload + "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/web-ui/scripts/gen-api-barrel.mjs b/src/web-ui/scripts/gen-api-barrel.mjs index 1cf5f06a4f..d49b1e5d2f 100644 --- a/src/web-ui/scripts/gen-api-barrel.mjs +++ b/src/web-ui/scripts/gen-api-barrel.mjs @@ -10,7 +10,7 @@ const dir = join(here, '..', 'src', 'generated', 'api'); const header = `// GENERATED CODE! DO NOT MODIFY BY HAND! // -// Source: src/crates/interfaces/app-server/src/schema.rs (+ upstream contract +// Source: src/crates/interfaces/app-server/src/schema/ (+ upstream contract // crates), exported via ts-rs (#[ts(export)], run by \`npm run gen:types\`). // This barrel re-exports every generated type so consumers can import from a // single path: \`import type { SubmitDialogTurnBody } from '@/generated/api'\`. @@ -23,6 +23,14 @@ const files = (await readdir(dir, { withFileTypes: true })) .map((e) => basename(e.name, extname(e.name))) .sort(); +const requiredTypes = ['ConfigUpdate']; +const missingTypes = requiredTypes.filter((typeName) => !files.includes(typeName)); +if (missingTypes.length > 0) { + throw new Error( + `TypeScript binding generation did not export required types: ${missingTypes.join(', ')}`, + ); +} + const lines = files.map((f) => `export type { ${f} } from './${f}';`); await writeFile(join(dir, 'index.ts'), header + '\n' + lines.join('\n') + '\n'); console.log(`gen-api-barrel: wrote ${files.length} re-exports to src/generated/api/index.ts`); 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 392d4c039d..7167e60993 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 @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { AGENT_COMMAND_SCHEMA, + decodeWsNotification, decodeResponseBody, encodeRequestBody, resolveWsMethod, @@ -20,6 +21,9 @@ describe('resolveWsMethod', () => { expect(resolveWsMethod('create_session')).toBe('agent/createSession'); expect(resolveWsMethod('list_sessions')).toBe('agent/listSessions'); expect(resolveWsMethod('delete_session')).toBe('agent/deleteSession'); + expect(resolveWsMethod('fork_session')).toBe('session/forkAtTurn'); + expect(resolveWsMethod('archive_session')).toBe('session/setArchived'); + expect(resolveWsMethod('unarchive_session')).toBe('session/setArchived'); // `start_dialog_turn` -> `agent/submitDialogTurn` (NOT `agent/submitTurn`): // the dialog-turn body carries `agentType`/`workspacePath`/`policy`. Mapping // it to `submitTurn` would silently drop `agentType` and omit the required @@ -96,7 +100,12 @@ describe('resolveWsMethod', () => { expect(resolveWsMethod('get_model_configs')).toBe('config/getModelConfigs'); expect(resolveWsMethod('get_config')).toBe('config/getConfig'); expect(resolveWsMethod('get_configs')).toBe('config/getConfigs'); - // Track B (Batch 1): config write + i18n surface. + expect(resolveWsMethod('set_agent_profile_config')).toBe( + 'config/setAgentProfileConfig' + ); + expect(resolveWsMethod('reset_agent_profile_config')).toBe( + 'config/resetAgentProfileConfig' + ); expect(resolveWsMethod('set_config')).toBe('config/setConfig'); expect(resolveWsMethod('i18n_get_current_language')).toBe( 'i18n/getCurrentLanguage' @@ -124,11 +133,11 @@ describe('resolveWsMethod', () => { // Runtime sanity: the schema entry carries the method string and the table // covers the schema methods (key count is stable; ordering is not pinned // because the table is a plain object). Track B Batch 1 added config write + - // i18n, raising the count from 20 to 26. + // i18n and the P0 Session/Config control plane, raising the count to 31. expect(AGENT_COMMAND_SCHEMA.start_dialog_turn.method).toBe( 'agent/submitDialogTurn' ); - expect(Object.keys(AGENT_COMMAND_SCHEMA).length).toBe(26); + expect(Object.keys(AGENT_COMMAND_SCHEMA).length).toBe(31); // Touch the locals so noUnusedLocals does not flag them under vitest's // transformed build (tsc --noEmit is the real gate; this is belt-and-suspenders). @@ -215,11 +224,72 @@ describe('encodeRequestBody', () => { expect(encoded.reply).toEqual({ reply: 'always' }); }); + it('encodes fork-at-turn and archive state into Session wire DTOs', () => { + expect(encodeRequestBody('fork_session', { + workspace_path: '/repo', + source_session_id: 's1', + source_turn_id: 't2', + remote_connection_id: 'remote-1', + })).toEqual({ + workspacePath: '/repo', + sourceSessionId: 's1', + sourceTurnId: 't2', + remoteConnectionId: 'remote-1', + }); + + expect(encodeRequestBody('archive_session', { + workspace_path: '/repo', + session_id: 's1', + })).toEqual({ + workspacePath: '/repo', + sessionId: 's1', + archived: true, + }); + expect(encodeRequestBody('unarchive_session', { + workspace_path: '/repo', + session_id: 's1', + })).toEqual({ + workspacePath: '/repo', + sessionId: 's1', + archived: false, + }); + }); + it('passes unknown actions through unchanged', () => { const body = { foo: 'bar' }; expect(encodeRequestBody('some_unknown_action', body)).toBe(body); expect(encodeRequestBody('list_sessions', body)).toBe(body); }); + + it('preserves the desktop success-string contract for profile mutations', () => { + expect(decodeResponseBody('set_agent_profile_config', { profile_id: 'agentic' })) + .toBe('Agent profile configuration updated successfully'); + expect(decodeResponseBody('reset_agent_profile_config', { profile_id: 'agentic' })) + .toBe('Agent profile configuration reset successfully'); + }); +}); + +describe('decodeWsNotification', () => { + it('projects typed config notifications to a stable frontend event', () => { + expect(decodeWsNotification({ + jsonrpc: '2.0', + method: 'config/event', + params: { kind: 'modelConfigurationUpdated' }, + })).toEqual({ + event: 'config://updated', + payload: { kind: 'modelConfigurationUpdated' }, + }); + }); + + it('keeps projected agent notifications unchanged', () => { + expect(decodeWsNotification({ + method: 'agent/frontendEvent', + params: { event: 'agentic://session-created', payload: { sessionId: 's1' } }, + })).toEqual({ + event: 'agentic://session-created', + payload: { sessionId: 's1' }, + }); + }); }); describe('decodeResponseBody', () => { 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 de1d46d2e6..32e4ce2b7b 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts @@ -3,13 +3,21 @@ import { ITransportAdapter } from './base'; import { createLogger } from '@/shared/utils/logger'; import type { + AgentSessionArchiveStateRequest, + AgentSessionForkAtTurnRequest, + ConfigUpdate, + ForkSessionResponse, GitBranch, GitRepositoryPathRequest, ListSessionsResponse, PermissionGrant, PermissionReply, RemoveProjectPermissionGrantResponse, + ResetAgentProfileConfigMessage, + ResetAgentProfileConfigResponse, RunResponse, + SetAgentProfileConfigMessage, + SetAgentProfileConfigResponse, SubmitDialogTurnBody, SubmitDialogTurnResponse, } from '@/generated/api'; @@ -19,7 +27,7 @@ const log = createLogger('WebSocketAdapter'); /** * Typed mapping from the frontend's snake_case agent commands to the app-server * JSON-RPC method names, carrying the request/response types from the generated - * schema (`@/generated/api`, source: `bitfun-app-server/schema.rs`). + * schema (`@/generated/api`, source: `bitfun-app-server/src/schema/`). * * The service layer (`AgentAPI` and friends) speaks Tauri command names * (`create_session`, `start_dialog_turn`, ...) because that is the desktop @@ -56,6 +64,19 @@ export const AGENT_COMMAND_SCHEMA = { response: null as unknown as ListSessionsResponse, }, delete_session: { method: 'agent/deleteSession' }, + fork_session: { + method: 'session/forkAtTurn', + request: null as unknown as AgentSessionForkAtTurnRequest, + response: null as unknown as ForkSessionResponse, + }, + archive_session: { + method: 'session/setArchived', + request: null as unknown as AgentSessionArchiveStateRequest, + }, + unarchive_session: { + method: 'session/setArchived', + request: null as unknown as AgentSessionArchiveStateRequest, + }, // `start_dialog_turn` maps to `agent/submitDialogTurn`, not `agent/submitTurn`: // the dialog-turn body carries `agentType`/`workspacePath`/`policy`, which // the bare submission request does not. This mirrors the desktop host, which @@ -124,7 +145,7 @@ export const AGENT_COMMAND_SCHEMA = { request: null as unknown as GitRepositoryPathRequest, response: null as unknown as { branches: GitBranch[] }, }, - // Config service surface (read-only in this batch). The agent-profile and + // Config service surface. The agent-profile and // model-config reads reach the global config singletons the Desktop host // also uses -- no service injection, mirroring the static `GitService` // pattern. `get_config`/`get_configs` carry the not-found -> undefined @@ -139,7 +160,16 @@ export const AGENT_COMMAND_SCHEMA = { get_model_configs: { method: 'config/getModelConfigs' }, get_config: { method: 'config/getConfig' }, get_configs: { method: 'config/getConfigs' }, - // Track B (Batch 1): config write + i18n surface. + set_agent_profile_config: { + method: 'config/setAgentProfileConfig', + request: null as unknown as SetAgentProfileConfigMessage, + response: null as unknown as SetAgentProfileConfigResponse, + }, + reset_agent_profile_config: { + method: 'config/resetAgentProfileConfig', + request: null as unknown as ResetAgentProfileConfigMessage, + response: null as unknown as ResetAgentProfileConfigResponse, + }, set_config: { method: 'config/setConfig' }, i18n_get_current_language: { method: 'i18n/getCurrentLanguage' }, i18n_set_language: { method: 'i18n/setLanguage' }, @@ -202,6 +232,31 @@ export function encodeRequestBody(action: string, body: any): any { case 'respond_permission': case 'respond_permission_batch': return encodePermissionResponseBody(body); + case 'fork_session': + return { + workspacePath: body.workspace_path, + sourceSessionId: body.source_session_id, + sourceTurnId: body.source_turn_id, + ...(body.remote_connection_id !== undefined + ? { remoteConnectionId: body.remote_connection_id } + : {}), + ...(body.remote_ssh_host !== undefined + ? { remoteSshHost: body.remote_ssh_host } + : {}), + }; + case 'archive_session': + case 'unarchive_session': + return { + workspacePath: body.workspace_path, + sessionId: body.session_id, + archived: action === 'archive_session', + ...(body.remote_connection_id !== undefined + ? { remoteConnectionId: body.remote_connection_id } + : {}), + ...(body.remote_ssh_host !== undefined + ? { remoteSshHost: body.remote_ssh_host } + : {}), + }; default: return body; } @@ -233,6 +288,10 @@ export function decodeResponseBody(action: string, result: any): any { return unwrapArray(result, 'records'); case 'git_get_branches': return unwrapArray(result, 'branches'); + case 'set_agent_profile_config': + return 'Agent profile configuration updated successfully'; + case 'reset_agent_profile_config': + return 'Agent profile configuration reset successfully'; default: return result; } @@ -297,6 +356,28 @@ export function webSocketResponseError(value: unknown): Error { return error; } +export interface DecodedWsNotification { + event: string; + payload: unknown; +} + +/** 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) { + return { + event: message.params.event, + payload: message.params.payload, + }; + } + if (message?.method === 'config/event' && message.params) { + return { + event: 'config://updated', + payload: message.params as ConfigUpdate, + }; + } + return null; +} + export class WebSocketTransportAdapter implements ITransportAdapter { private ws: WebSocket | null = null; private url: string; @@ -420,9 +501,10 @@ export class WebSocketTransportAdapter implements ITransportAdapter { // 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/frontendEvent' && message.params?.event) { - const eventName: string = message.params.event; - const payload = message.params.payload; + 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 => { From fba47b7759eb8116618eb56b8affcfe70d26c606 Mon Sep 17 00:00:00 2001 From: weishao Date: Wed, 5 Aug 2026 16:21:07 +0800 Subject: [PATCH 2/2] docs(architecture): address app server review feedback --- .../agent-runtime-deployment-design.md | 6 +++--- docs/architecture/app-server-architecture.md | 2 +- .../tui-app-server-decoupling-refactor-plan.md | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index f5a05b6f9e..0275e7e322 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -236,7 +236,7 @@ Desktop 作为 ACP client 管理的外部 agent Session 不经过该 Runtime own sequenceDiagram participant C as Shared TUI client participant D as User-private discovery - participant S as Shared Runtime process + participant S as Shared Runtime Host process C->>D: read endpoint + token + identity + protocol C->>S: connect via Named Pipe / UDS @@ -387,7 +387,7 @@ flowchart TB Headless["Headless / CI"] --> Runtime end subgraph Shared["显式 --shared"] - Clients["one or more TUI processes"] -->|"Named Pipe / UDS · current compatibility"| SharedRuntime["Shared Runtime process"] + Clients["one or more TUI processes"] -->|"Named Pipe / UDS · current compatibility"| SharedRuntime["Shared Runtime Host process"] end Runtime --> Data["workspace + Session storage"] SharedRuntime --> Data @@ -461,7 +461,7 @@ sequenceDiagram T-->>U: remove only after applied ``` -Embedded 和 Shared 最终调用同一 `AgentRuntime::delete_session`。Shared Server 只在请求方没有活动 Turn、目标 Session 未被任何 Client 控制时调用 Runtime owner;`session_in_use` 和 `not_found` 保持结构化错误。TUI 复用现有单个 Session 异步任务槽位,不阻塞事件循环,也不自动重试结果不确定的删除。 +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 异步任务槽位,不阻塞事件循环,也不自动重试结果不确定的删除。 ## 6. 隔离和生命周期原则 diff --git a/docs/architecture/app-server-architecture.md b/docs/architecture/app-server-architecture.md index cbe41a594d..765d7afc61 100644 --- a/docs/architecture/app-server-architecture.md +++ b/docs/architecture/app-server-architecture.md @@ -99,7 +99,7 @@ App Server 的目标是提供一个可版本化、可生成 client、可跨 Embe - 强制 Headless CLI/CI、ACP、Peer Host 或公开 Agent SDK 使用 App Server。 - 统一 GUI 与 TUI 的状态机、renderer、布局、主题键或键位模型。 - 把 WebSocket transport 宣称为已具备多用户或公网安全性的公开 API。 -- 为旧 Tauri command、旧 Web route 或 Runtime IPC 永久建立平行兼容合同。 +- 不允许临时兼容路径在没有明确决策、维护责任、版本规则和退出条件的情况下意外变成永久协议。若最终选择候选 B/C,应把保留的 Shared wire 明确定义为正式的部署专用协议,而不是继续称为临时兼容路径。 ## 4. 术语 diff --git a/docs/plans/tui-app-server-decoupling-refactor-plan.md b/docs/plans/tui-app-server-decoupling-refactor-plan.md index 2edbdd5892..6f1c566843 100644 --- a/docs/plans/tui-app-server-decoupling-refactor-plan.md +++ b/docs/plans/tui-app-server-decoupling-refactor-plan.md @@ -2,7 +2,7 @@ > 状态:Phase 0-2 已完成当前定义的边界、协议基础和核心聊天迁移;Phase 3-5 尚未开始。 > -> 当前状态基线:2026-08-05,head `e6705251`。 +> 当前状态基线:2026-08-05。一次性的运行证据保留在对应 PR/Actions 记录中;本文不绑定会因 rebase 失效的提交 SHA。 > > 本文只记录当前差距、阶段和完成证据。稳定架构约束见相邻架构文档;Phase 0 的历史盘点已失效,不再作为当前能力清单。 @@ -52,7 +52,7 @@ Shared TUI (--shared) -> TuiBackend -> SharedTuiBackend compatibility adapter -> private Runtime IPC v17 - -> Shared Runtime process + -> Shared Runtime Host process -> Runtime API / owners ``` @@ -177,11 +177,11 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll 计划状态以完成条件和验证证据为准,不以 method 数量或文件存在为准: -| 阶段 | 完成条件 | 验证方式 | 当前状态 | Head | +| 阶段 | 完成条件 | 验证方式 | 当前状态 | 验证记录 | | --- | --- | --- | --- | --- | -| Phase 0:边界 | `TuiBackend`、behavior-light protocol/client crate、source/Cargo guard 已建立 | Core boundary tests 和 dependency checks | 已完成 | `e6705251` | -| Phase 1:协议基础 | initialize/health、typed events、connection-local cursor、resync、稳定错误和 Embedded connection 已接线 | App Server protocol/client/server focused tests | 已完成 | `e6705251` | -| Phase 2:核心聊天 | Embedded 核心用例经 App Server;Shared 经同一 `TuiBackend` 映射 v17;TUI 核心不引用 Runtime SDK/IPC operation | CLI、App Server、Runtime IPC 和 boundary focused tests | 已完成当前定义 | `e6705251` | +| 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 3:配置管理 | TUI 不再访问 config/registry/MCP compatibility owner;secret-safe typed APIs 完成 | owner tests、App Server contract tests、CLI behavior tests | 未开始 | - | | Phase 4:外部集成 | External Source、Hook、Account、Worktree 管理面经 typed backend;remote 不回落本机 | owner/remote/security contract tests | 未开始 | - | | Phase 5:Shared App Server | Shared Host 达到 v17 治理等价,opt-in 双栈验证完成,并有回滚与删除证据 | 跨 transport parity、故障、性能和安全测试 | 未开始,目标待评审 | - | @@ -244,7 +244,7 @@ cargo test -p bitfun-cli pnpm run check:core-boundaries ``` -按本 PR 的 Phase 2 实施记录,`e6705251` 已通过 CLI、App Server、Runtime IPC、behavior-light interface crates 和 Core boundary 的 focused checks。两条 headless exec Ctrl+C 断言在隔离基线中同样失败,未计为 Phase 2 回归。后续阶段必须在各自 head 重新记录命令结果,不能沿用此处证据。 +Phase 0-2 的具体命令结果和 CI 状态保留在 [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) 中,本文只保留可重复执行的验证命令和阶段状态。后续阶段必须在各自变更中重新记录验证结果,不能沿用一次性提交 SHA 作为证据。 ### 6.2 行为等价场景