diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..9d2e7ee3c --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,10 @@ +# ts-rs (Rust -> TypeScript) export configuration. +# `cargo test --features ts` (or the frontend `gen:types` script) emits `.ts` +# files into the directory below. Generated types are NOT committed (see +# `.gitignore`); the `prebuild` step regenerates them locally and in CI. +# See docs/plans/step1-ts-rs-integration.md. +[env] +TS_RS_EXPORT_DIR = { value = "src/web-ui/src/generated/api", relative = true } +# Map i64/u64 to `number` (not `bigint`) so generated types match the existing +# frontend convention (timestamps are `number` ms throughout the web UI). +TS_RS_LARGE_INT = "number" diff --git a/.gitignore b/.gitignore index 39499a6d2..7c68b590b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,10 @@ src/web-ui/src/generated/version.ts src/web-ui/src/generated/version-injection.html src/web-ui/public/version.json +# Generated TypeScript bindings (ts-rs) — single source is Rust schema.rs. +# Regenerated by `npm run gen:types` / build prebuild step. +src/web-ui/src/generated/api/ + # Tauri generated files apps/desktop/gen/ src/apps/desktop/gen/ diff --git a/Cargo.toml b/Cargo.toml index ae6cace31..c08bab215 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "src/apps/server", "src/apps/relay-server", "src/crates/interfaces/acp", + "src/crates/interfaces/app-server", "src/crates/interfaces/sdk-host", "src/crates/adapters/agent-runtime-ipc", "src/crates/assembly/core", @@ -82,6 +83,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" +# TypeScript binding generation (schema-first; gated by per-crate `ts` features) +ts-rs = { version = "12", features = ["serde-json-impl", "no-serde-warnings"] } + # Error handling anyhow = "1.0" thiserror = "2" diff --git a/docs/architecture/agent-runtime-lifecycle-sequence.md b/docs/architecture/agent-runtime-lifecycle-sequence.md new file mode 100644 index 000000000..08a981181 --- /dev/null +++ b/docs/architecture/agent-runtime-lifecycle-sequence.md @@ -0,0 +1,254 @@ +# Agent Runtime 生命周期时序 + +本文件是 [`agent-runtime-services-design.md`](agent-runtime-services-design.md) 的生命周期补充, +描述 `bitfun-agent-runtime` 的 `sdk` / `AgentRuntime` 门面如何把智能体(agent)从构建到销毁的 +全生命周期映射到具体接口与底层 Port。当前接口名称、字段和消费方以代码为准: +[sdk.rs](../../src/crates/execution/agent-runtime/src/sdk.rs)、 +[runtime.rs](../../src/crates/execution/agent-runtime/src/runtime.rs)、 +[session_state.rs](../../src/crates/execution/agent-runtime/src/session_state.rs)、 +[events.rs](../../src/crates/execution/agent-runtime/src/events.rs)、 +[scheduler.rs](../../src/crates/execution/agent-runtime/src/scheduler.rs)、 +[post_call_hooks.rs](../../src/crates/execution/agent-runtime/src/post_call_hooks.rs)。 + +> 范围:本文件只描述 `agent-runtime` crate 暴露的稳定 SDK 接口与对应 Port 的调用顺序,不构成 +> 对内部协调器、调度器、持久化或具体工具执行的承诺。`run()` 是 SDK 层对「会话解析 + turn 提交」 +> 的编排封装,真实 turn 执行由 `bitfun-core` owner 在端口实现侧完成。 + +## 生命周期阶段总览 + +| 阶段 | 含义 | 入口 | +|---|---|---| +| ① 构建 Build | 注入 Port、校验可用性,构造 `AgentRuntime` | `AgentRuntimeBuilder` | +| ② 会话创建 Create | 建立会话、解析 agent 类型、订阅事件 | `create_session` / `run(Create)` | +| ③ 会话管理 Manage | 列表/重命名/归档、切换模型与模式 | `list/rename/archive/update_*` | +| ④ 提交/运行 Run | 把消息投递为 turn | `run` / `submit_turn` / `submit_dialog_turn` | +| ⑤ 处理中 Process | 权限确认、用户问答、后台回投、事件广播 | `respond_permission` / `deliver_*` / `publish_event` | +| ⑥ 结算 Settle | 等 turn 结束、读 transcript、算 usage、钩子 | `wait_for_turn_settlement` / `generate_session_usage` | +| ⑦ 中断/恢复 Cancel/Restore/Fork | 取消、恢复、分叉会话 | `cancel_turn` / `restore_session` / `fork_*` | +| ⑧ 销毁 Delete | 删除会话 | `delete_session` | + +## 时序图 + +``` +调用方(SDK) AgentRuntime(sdk.rs) 底层Port(runtime.rs) 事件/状态 bitfun-core实现 + │ │ │ │ │ + │═══════════════════════╗│ │ │ │ + │ ① 构建期 (Build) ║│ │ │ │ + │═══════════════════════╝│ │ │ │ + │ AgentRuntimeBuilder │ │ │ │ + │::new() │ │ │ │ + ├──────────────────────►│ with_submission_port() │ │ │ + │ with_*_port() 注入端口│ with_dialog_turn_port() │ │ │ + │ (submission/dialog/ │ with_lifecycle_delivery │ │ │ + │ cancellation/...等) │ _port() │ │ │ + │ │ with_cancellation_port() │ │ │ + │ │ with_services() │ │ │ + │ │ with_event_stream() │ │ │ + │ │ build() ─────────────► │ 校验: │ │ + │ │ │ submission必填 │ │ + │ │ │ plugin_runtime校验 │ │ + │ │◄─────────────────────────│ AgentRuntime{} │ │ + │◄───────────────────────│ AgentRuntime │ │ │ + │ │ │ │ │ + │═══════════════════════╗│ │ │ │ + │ ② 会话创建期 (Create) ║│ │ │ │ + │═══════════════════════╝│ │ │ │ + │ subscribe_events() │ │ │ │ + ├──────────────────────►│ event_source.subscribe() │ │ 注册事件接收器 │ + │ create_session(req) │ │ │ │ + ├──────────────────────►│ submission │ │ │ + │ ├─────────────────────────►│ create_session() │ │ + │ │ ├──────────────────► │ │ + │ │ │ (session_id) │ │ + │ │◄─────────────────────────│ AgentSessionCreate │ │ + │◄───────────────────────│ Result │ Result │ │ + │ │ │ │ │ + │ resolve_session_ │ │ │ │ + │ agent_type(sid) │ │ │ │ + ├──────────────────────►│ submission ────────────►│ resolve_session_ │ │ + │◄───────────────────────│ ◄────────────────────────│ agent_type() │ │ + │ │ │ │ │ + │═══════════════════════╗│ │ │ │ + │ ③ 会话管理期 (Manage) ║│ │ │ │ + │═══════════════════════╝│ │ │ │ + │ list_sessions() │ │ │ │ + ├──────────────────────►│ session_management ────►│ list_sessions() │ │ + │ rename_session() │ (port) ├──────────────────► │ │ + ├──────────────────────►│ │ rename_session() │ │ + │ archive_session() │ ├──────────────────► │ │ + ├──────────────────────►│ │ archive_session() │ │ + │ update_session_model()│ session_model ─────────►│ update_session_model│ │ + ├──────────────────────►│ │ │ │ + │ update_session_mode() │ session_mode ──────────►│ update_session_mode │ │ + ├──────────────────────►│ │ │ │ + │ resolve_session_ │ session_management ────►│ resolve_session_ │ │ + │ workspace_binding() │ │ workspace_binding() │ │ + │◄───────────────────────│ │ │ │ + │ │ │ │ │ + │═══════════════════════╗│ │ │ │ + │ ④ 提交/运行期 (Run) ║│ │ │ │ + │═══════════════════════╝│ │ │ │ + │ run(AgentRunRequest) │ │ │ │ + ├──────────────────────►│ 若 Create: create_session│ │ │ + │ ├─────────────────────────►│ create_session() │ │ + │ │ 若 Existing: resolve_ │ │ │ + │ │ session_agent_type() │ │ │ + │ │ submit_turn() │ │ │ + │ ├─────────────────────────►│ submission │ │ + │ │ │ .submit_message() │ │ + │ │ ├──────────────────► │ publish_event() │ + │ │ │ │ TurnStarted │ + │ │ │ │ SessionState→ │ + │ │ │ │ Processing(Starting) │ + │ │◄─────────────────────────│ AgentSubmissionResult│ │ + │◄───────────────────────│ AgentRunHandle │ │ (turn_id,accepted) │ + │ │ {session_id,turn_id, │ │ │ + │ │ agent_type,events} │ │ │ + │ │ │ │ │ + │ ── 或直接用细分接口 ── │ │ │ │ + │ submit_turn(req) │ │ │ │ + ├──────────────────────►│ submission.submit_message│ │ │ + │ submit_dialog_turn() │ dialog_turn ────────────►│ submit_dialog_turn()│ (远端对话/带优先级队列) │ + ├──────────────────────►│ │ │ │ + │ │ │ │ │ + │═══════════════════════╗│ │ │ │ + │ ⑤ 处理中事件流 (Process)║│ │ │ │ + │═══════════════════════╝│ │ │ │ + │ │ │ │ ProcessingPhase流转: │ + │ │ │ │ Starting→Compacting→ │ + │ │ │ │ Thinking→Streaming→ │ + │ │ │ │ ToolCalling │ + │ │ │ │ ┊ │ + │ │ │ │ ▼ 工具确认 │ + │ pending_permission_ │ permission_requests ──►│ interactive_pending │ │ + │ requests() │ │ _requests() │ │ + ├──────────────────────►│ subscribe_permission_ │ │ PermissionRequestEvent│ + │ respond_permission() │ requests() │ │ │ + ├──────────────────────►│ permission_requests ────►│ reply() │ PermissionReply │ + │ │ │ │ →ToolConfirming │ + │ │ │ │ ┊ │ + │ │ │ │ ▼ 用户问题工具 │ + │ submit_user_answers() │ interaction_response ──►│ submit_user_answers │ │ + ├──────────────────────►│ │ () │ │ + │ │ │ │ │ + │ publish_event() │ services.events 或 │ │ │ + ├──────────────────────►│ event_stream ──────────►│ publish_runtime_ │ RuntimeEventEnvelope │ + │ │ │ event() │ (SessionStateChanged) │ + │ │ │ │ │ + │ (后台结果/线程目标回投) │ │ │ │ + │ deliver_background_ │ lifecycle_delivery ────►│ deliver_background_ │ │ + │ result() │ │ result() │ │ + ├──────────────────────►│ │ │ 注入到当前/精确turn │ + │ deliver_thread_goal() │ lifecycle_delivery ────►│ deliver_thread_goal │ │ + ├──────────────────────►│ │ () │ │ + │ │ │ │ │ + │ (线程目标管理) │ │ │ │ + │ create_thread_goal() │ thread_goal_management ►│ create_thread_goal() │ │ + │ get_thread_goal() │ │ get_thread_goal() │ │ + │ update_thread_goal_ │ │ update_thread_goal_ │ │ + │ status() │ │ status() │ │ + │ │ │ │ │ + │═══════════════════════╗│ │ │ │ + │ ⑥ 结算/完成期 (Settle) ║│ │ │ │ + │═══════════════════════╝│ │ │ │ + │ wait_for_turn_ │ turn_settlement ───────►│ wait_for_turn_ │ │ + │ settlement(req) │ │ settlement() │ │ + ├──────────────────────►│ │ │ 阻塞至turn结束 │ + │ │ │ │ ┊ │ + │ │ │ │ ▼ TurnOutcome │ + │ │ │ │ Completed/Cancelled/ │ + │ │ │ │ Failed │ + │ │ │ │ →publish TurnCompleted/│ + │ │ │ │ TurnCancelled/TurnFailed│ + │ │ │ │ →SessionState→Idle/Error│ + │ │ │ │ │ + │ (post-call hooks) │ │ │ │ + │ │ hook_registry.hooks() │ │ 成功工具调用后: │ + │ │ run_successful_tool_ │ │ DeepReviewSharedContext│ + │ │ post_call_hooks() │ │ ToolUse │ + │ │ │ │ │ + │ generate_session_ │ session_usage ─────────►│ generate_session_ │ │ + │ usage() │ │ usage() │ SessionUsageReport │ + ├──────────────────────►│ │ │ │ + │ read_session_ │ session_transcript_ │ │ │ + │ transcript() │ reader ────────────────►│ read_session_ │ │ + ├──────────────────────►│ │ transcript() │ │ + │ record_completed_ │ local_command_turn ────►│ record_completed_ │ (本地CLI命令记录) │ + │ local_command_turn()│ │ local_command_turn │ │ + │◄───────────────────────│ │ () │ │ + │ │ │ │ │ + │═══════════════════════╗│ │ │ │ + │ ⑦ 中断/恢复期 (Cancel/ ║│ │ │ │ + │ Restore/Fork) ║│ │ │ │ + │═══════════════════════╝│ │ │ │ + │ cancel_turn(req) │ │ │ │ + ├──────────────────────►│ cancellation ───────────►│ cancel_turn() │ →TurnCancelled事件 │ + │◄───────────────────────│ ◄────────────────────────│ AgentTurnCancelRes │ →SessionState→Idle │ + │ │ │ │ │ + │ restore_session(req) │ │ │ │ + ├──────────────────────►│ session_restore ────────►│ restore_session() │ 重新加载历史session │ + │◄───────────────────────│ ◄────────────────────────│ AgentSessionRestore│ →SessionState恢复 │ + │ │ │ Result │ │ + │ │ │ │ │ + │ fork_session() / │ session_fork ───────────►│ fork_session() / │ 从某turn分叉新session │ + │ fork_session_at_turn()│ │ fork_session_ │ │ + ├──────────────────────►│ │ at_turn() │ │ + │ │ │ │ │ + │═══════════════════════╗│ │ │ │ + │ ⑧ 销毁期 (Delete) ║│ │ │ │ + │═══════════════════════╝│ │ │ │ + │ delete_session(req) │ │ │ │ + ├──────────────────────►│ session_management ────►│ delete_session() │ │ + │◄───────────────────────│ │ │ │ +``` + +## 生命周期阶段 ↔ 接口映射表 + +| 阶段 | 触发的 SDK 接口([sdk.rs](../../src/crates/execution/agent-runtime/src/sdk.rs)) | 委托的底层 Port([runtime.rs](../../src/crates/execution/agent-runtime/src/runtime.rs)) | 关键状态/事件 | +|---|---|---|---| +| **① 构建 Build** | `AgentRuntimeBuilder::new()` + `with_*_port()` + `build()` | 注入 `submission`(必填)/`dialog_turn`/`lifecycle_delivery`/`cancellation`/`session_management`/`session_restore`/`turn_settlement`/`session_model`/`session_mode`/`session_fork`/`session_usage`/`local_command_turn`/`session_transcript_reader`/`thread_goal_management`/`interaction_response`/`permission_requests`/`services`/`event_stream`/`event_source` | build 校验:`submission` 必填;`plugin_runtime` 可用性 | +| **② 会话创建 Create** | `subscribe_events()`、`create_session()`、`create_session_with_id()`、`resolve_session_agent_type()` | `AgentSubmissionPort::create_session()` / `create_session_with_id()` / `resolve_session_agent_type()` | `AgentSessionCreateResult{session_id}` | +| **③ 会话管理 Manage** | `list_sessions()`、`rename_session()`、`archive_session()`、`set_session_archived()`、`update_session_model()`、`update_session_mode()`、`resolve_session_workspace_binding()` | `AgentSessionManagementPort`、`AgentSessionModelPort`、`AgentSessionModePort` | 元数据/归档/模型/模式切换 | +| **④ 提交/运行 Run** | `run()`(高层封装)、`submit_turn()`、`submit_dialog_turn()` | `run()` 内部 = `create_session` 或 `resolve_session_agent_type` + `submit_turn`;`AgentSubmissionPort::submit_message()`;`AgentDialogTurnPort::submit_dialog_turn()` | 发 `TurnStarted`;`SessionState→Processing(Starting)`;返回 `AgentRunHandle` | +| **⑤ 处理中 Process** | `pending_permission_requests()`、`subscribe_permission_requests()`、`respond_permission()`、`submit_user_answers()`、`deliver_background_result()`、`deliver_thread_goal()`、`create/get/update_thread_goal_status()`、`publish_event()` | `PermissionRequestManager`、`AgentInteractionResponsePort::submit_user_answers()`、`AgentLifecycleDeliveryPort::deliver_background_result/deliver_thread_goal()`、`AgentThreadGoalManagementPort`、`RuntimeEventSink`(services.events) + `AgentEventStream` | `ProcessingPhase` 流转:Starting→Compacting→Thinking→Streaming→ToolCalling→ToolConfirming;发 `RuntimeEventType::SessionStateChanged` | +| **⑥ 结算 Settle** | `wait_for_turn_settlement()`、`read_session_transcript()`、`generate_session_usage()`、`record_completed_local_command_turn()` | `AgentTurnSettlementPort::wait_for_turn_settlement()`、`SessionTranscriptReader`、`AgentSessionUsagePort`、`AgentLocalCommandTurnPort`;`hook_registry` 内 `run_successful_tool_post_call_hooks()` | `TurnOutcome` Completed/Cancelled/Failed;发 `TurnCompleted`/`TurnCancelled`/`TurnFailed`;`SessionState→Idle/Error`;`SessionUsageReport` | +| **⑦ 中断/恢复 Cancel/Restore/Fork** | `cancel_turn()`、`restore_session()`、`fork_session()`、`fork_session_at_turn()` | `AgentTurnCancellationPort::cancel_turn()`、`AgentSessionRestorePort::restore_session()`、`AgentSessionForkPort::fork_session()/fork_session_at_turn()` | `AgentTurnCancellationResult`;`AgentSessionRestoreResult{session,state}` | +| **⑧ 销毁 Delete** | `delete_session()` | `AgentSessionManagementPort::delete_session()` | 会话移除 | + +## 关键设计要点 + +1. **分层门面([sdk.rs](../../src/crates/execution/agent-runtime/src/sdk.rs) 顶部)**:`sdk.rs` 是 `runtime.rs` + 的稳定门面,仅暴露 ports / registry / 事件源,不暴露 Plugin Runtime Host ABI;客户端消费 `sdk`, + 产品组装消费内部 `runtime`。 + +2. **`run()` 是生命周期的编排核心([runtime.rs:1231-1278](../../src/crates/execution/agent-runtime/src/runtime.rs#L1231-L1278))**: + 它把「会话解析 → turn 提交」串联: + - `SessionSelector::Create` → `create_session()` + - `SessionSelector::Existing` → `resolve_session_agent_type()`(不重复建会话) + - 最后统一调 `submit_turn()` → `submission.submit_message()` + +3. **turn 处理相位流转([session_state.rs:31-39](../../src/crates/execution/agent-runtime/src/session_state.rs#L31-L39))**: + `ProcessingPhase` = `Starting / Compacting / Thinking / Streaming / ToolCalling / ToolConfirming`,对应模型推理、 + 流式输出、工具调用、权限确认等子阶段,通过 `publish_event()` 以 `RuntimeEventType::SessionStateChanged` + 广播。 + +4. **三条交互旁路**(处理中可并发进入): + - **权限确认旁路**:`pending_permission_requests` → `respond_permission` → 解锁 `ToolConfirming` 相位 + - **用户问答旁路**:`submit_user_answers` → 回答挂起的 user-question 工具 + - **后台回投旁路**:`deliver_background_result` / `deliver_thread_goal` → 注入到当前 / 精确 turn 的下一轮 + +5. **结算同步点([runtime.rs:1038-1051](../../src/crates/execution/agent-runtime/src/runtime.rs#L1038-L1051))**: + `wait_for_turn_settlement()` 是阻塞调用,调用方用它等 turn 进入 + `TurnOutcome::{Completed, Cancelled, Failed}`,之后才读 transcript / 算 usage。 + +6. **post-call hooks([post_call_hooks.rs:156-172](../../src/crates/execution/agent-runtime/src/post_call_hooks.rs#L156-L172))**: + 仅在「成功工具调用后」(`SuccessfulToolPostCall`)触发,当前唯一具体钩子是 + `DeepReviewSharedContextToolUse`,由 `hook_registry` 驱动,不影响主线生命周期但附加观测副作用。 + +7. **Port 为可选注入**:除 `submission` 外所有 Port 都是 `Option`;缺端口时调用对应接口会返回 + `RuntimeError::Missing*`(如 `MissingDialogTurnPort`、`MissingCancellationPort`),因此同一 `AgentRuntime` + 可在不同产品 profile 下裁剪能力。 + +8. **事件双通道**:`publish_event()` 会同时写入注入的 `RuntimeServices` 事件 sink(跨进程/跨产品广播, + `RuntimeEventType` 如 `TurnStarted`/`TurnCompleted`)与进程内 `AgentEventStream`(`run()` 返回的 + `AgentRunHandle.events` 供调用方 drain 快照),二者互不阻塞。 diff --git a/scripts/build-web-parallel.mjs b/scripts/build-web-parallel.mjs index 41c583368..6d4d1d860 100644 --- a/scripts/build-web-parallel.mjs +++ b/scripts/build-web-parallel.mjs @@ -1,12 +1,16 @@ #!/usr/bin/env node /** - * Runs the web-ui type-check (tsc --noEmit) and the Vite production build in - * parallel. The two are independent: Vite transpiles with esbuild and never - * consults tsc, so serializing them only added wall-clock time. + * Regenerates TypeScript bindings from the Rust schema, then runs the web-ui + * type-check (tsc --noEmit) and the Vite production build in parallel. The + * latter two are independent: Vite transpiles with esbuild and never consults + * tsc, so serializing them only added wall-clock time. * - * Both child processes must succeed; if either fails the script exits with a - * non-zero code (after letting the sibling finish so its output is not lost). + * The `gen:types` step (which requires a Rust toolchain) runs serially first so + * the Vite build picks up fresh bindings. Local frontend-only iteration can + * skip it with `pnpm --dir src/web-ui build`. Both child processes must succeed; + * if either fails the script exits with a non-zero code (after letting the + * sibling finish so its output is not lost). */ import { spawn } from 'node:child_process'; @@ -54,13 +58,30 @@ function runPrefixed(prefix, command, args, cwd) { }); } +// Step 1: regenerate TypeScript bindings from the Rust schema (serial, must +// finish before the Vite build so the frontend picks up fresh types). This is +// the only step that requires a working Rust toolchain; local frontend-only +// iteration can skip it with `pnpm --dir src/web-ui build`. +const genTypesCode = await runPrefixed( + 'gen-types', + 'pnpm', + ['--dir', 'src/web-ui', 'run', 'gen:types'], + ROOT_DIR, +); +if (genTypesCode !== 0) { + process.stderr.write('[build-web-parallel] gen:types failed (see output above)\n'); + process.exitCode = 1; + process.exit(); +} + +// Step 2: type-check and Vite build run in parallel. const tasks = [ runPrefixed('type-check', 'pnpm', ['run', 'type-check:web'], ROOT_DIR), runPrefixed('vite-build', 'pnpm', ['--dir', 'src/web-ui', 'build'], ROOT_DIR), ]; -const codes = await Promise.all(tasks); -const failed = codes.some((code) => code !== 0); +const buildCodes = await Promise.all(tasks); +const failed = buildCodes.some((code) => code !== 0); if (failed) { process.stderr.write('[build-web-parallel] build:web failed (see output above)\n'); } diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index 5ec05e51f..2609ebf7e 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -27,6 +27,7 @@ export const crateLayoutRules = [ { crateName: 'terminal', layer: 'services', path: 'src/crates/services/terminal' }, { crateName: 'acp', layer: 'interfaces', path: 'src/crates/interfaces/acp' }, + { crateName: 'app-server', layer: 'interfaces', path: 'src/crates/interfaces/app-server' }, { 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/src/apps/server/Cargo.toml b/src/apps/server/Cargo.toml index f2db9afe7..7753b8fe4 100644 --- a/src/apps/server/Cargo.toml +++ b/src/apps/server/Cargo.toml @@ -12,6 +12,15 @@ path = "src/main.rs" [dependencies] bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] } +# App-server surface: an in-process JSON-RPC server/client pair over an +# in-memory channel transport. The websocket handler routes agent kernel RPCs +# through this client so agent interfaces uniformly go through app-server. +bitfun-app-server = { path = "../../crates/interfaces/app-server" } +bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } +bitfun-events = { path = "../../crates/contracts/events" } + +agent-client-protocol = { workspace = true } + # Web framework axum = { workspace = true } tower-http = { workspace = true } @@ -26,8 +35,10 @@ base64 = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } futures-util = { workspace = true } +futures = { workspace = true } chrono = { workspace = true } dirs = { workspace = true } +log = { workspace = true } [lints] workspace = true diff --git a/src/apps/server/src/app_server.rs b/src/apps/server/src/app_server.rs new file mode 100644 index 000000000..5eb97a09d --- /dev/null +++ b/src/apps/server/src/app_server.rs @@ -0,0 +1,28 @@ +//! Server-host app-server wiring: build the in-process `BitfunAppServer` from +//! the product-assembled [`AgentRuntime`] and return a cloneable handle. +//! +//! Under browser-direct ACP-over-WS (Step 2) the server host no longer pairs +//! the app-server with an in-process client over `in_memory_pair`. Instead each +//! WebSocket connection is handed straight to [`BitfunAppServer::serve`] via the +//! [`crate::routes::ws_transport`] `Lines` adapter, so the browser connects +//! directly to the in-process app-server over native JSON-RPC. This module only +//! constructs the [`BitfunAppRuntime`] and wraps it in a [`BitfunAppServer`] +//! (cheap `Clone` via the inner `Arc`); `serve` runs once per WS connection. + +use bitfun_agent_runtime::sdk::{AgentEventSource, AgentRuntime}; +use bitfun_app_server::{BitfunAppRuntime, BitfunAppServer}; + +/// Build the in-process `BitfunAppServer` for the Server Host. +/// +/// Constructs a [`BitfunAppRuntime`] from the product-assembled `runtime` and +/// its `event_source`, wraps it in a [`BitfunAppServer`] (cheap `Clone`), and +/// returns it. The websocket handler clones this handle once per connection and +/// spawns `serve` on a WS-bridged `Lines` transport. +/// +/// The caller must keep the runtime services (coordinator, scheduler, ...) and +/// the `EventQueue` the `event_source` was built from alive for as long as the +/// [`BitfunAppServer`] is in use. +pub(crate) fn build(runtime: AgentRuntime, event_source: AgentEventSource) -> BitfunAppServer { + let app_runtime = BitfunAppRuntime::new(runtime, event_source); + BitfunAppServer::new(app_runtime) +} diff --git a/src/apps/server/src/bootstrap.rs b/src/apps/server/src/bootstrap.rs index 2b41c1404..94817d50b 100644 --- a/src/apps/server/src/bootstrap.rs +++ b/src/apps/server/src/bootstrap.rs @@ -10,7 +10,12 @@ use std::sync::Arc; use tokio::sync::RwLock; /// Shared application state for the server (mirrors Desktop's AppState). -pub struct ServerAppState { +/// +/// Several fields are stored to keep the corresponding services alive (they +/// register global singletons during `initialize`), not because they are read +/// again after initialization. +#[allow(dead_code)] +pub(crate) struct ServerAppState { pub ai_client_factory: Arc, pub workspace_service: Arc, pub workspace_path: Arc>>, @@ -30,7 +35,7 @@ pub struct ServerAppState { /// Initialize all core services and return the shared server state. /// /// The optional `workspace` path, when provided, is opened automatically. -pub async fn initialize(workspace: Option) -> anyhow::Result> { +pub(crate) async fn initialize(workspace: Option) -> anyhow::Result> { log::info!("Initializing BitFun server core services"); // 1. Global config @@ -137,9 +142,12 @@ pub async fn initialize(workspace: Option) -> anyhow::Result>` (~20 request +// handlers plus a dispatch handler). Computing the resulting connection +// future's layout pushes the trait solver past the default 128 limit, so bump +// it. Matches the compiler's own suggestion in the overflow diagnostic. +#![recursion_limit = "256"] + use anyhow::Result; /// BitFun Server /// @@ -15,6 +22,8 @@ use serde::Serialize; use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc}; use tower_http::cors::CorsLayer; +mod app_server; +mod bootstrap; mod routes; pub(crate) struct DispatchHostState { @@ -25,6 +34,10 @@ pub(crate) struct DispatchHostState { /// Application state #[derive(Clone)] pub struct AppState { + // NOTE(Step 2a): only read by the external_sources dispatch path, which is + // temporarily dead under browser-direct ACP-over-WS. Kept for the follow-up + // that brings external_sources onto the app-server schema. + #[allow(dead_code)] external_workspace_root: Option, allowed_browser_origins: Arc>, dispatch_host: Option>, @@ -82,6 +95,43 @@ async fn main() -> Result<()> { .map_err(|error| anyhow::anyhow!("Could not open Server workspace: {error}")) }) .transpose()?; + + // Initialize the full agentic stack (coordinator, scheduler, token usage, + // MCP/config/filesystem services, event queue). This binding is held alive + // for the lifetime of the server so its services outlive every websocket + // connection; the app-server client and spawned tasks hold their own Arc + // clones of the coordinator, scheduler, and event queue. + let server_state = bootstrap::initialize( + external_workspace_root + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), + ) + .await?; + + // Build the agent runtime the same way the Desktop session application does, + // then build an in-process `BitfunAppServer` for it. Each WebSocket + // connection is handed straight to `BitfunAppServer::serve` over a WS-bridged + // `Lines` transport (browser-direct ACP-over-WS, Step 2), so the browser + // connects directly to the in-process app-server over native JSON-RPC — no + // shared in-process client, no custom WS envelope. + let agent_runtime = + bitfun_core::product_runtime::CoreProductAgentRuntime::build_session_surface( + server_state.coordinator.clone(), + server_state.scheduler.clone(), + server_state.token_usage_service.clone(), + ) + .map_err(|error| anyhow::anyhow!("Failed to build agent runtime: {error}"))?; + // The event source wraps the same `EventQueue` the coordinator publishes to; + // each connection's `serve` main loop subscribes independently and projects + // runtime events to the frontend shape before pushing them to the browser. + let event_source = + bitfun_agent_runtime::sdk::AgentEventSource::new(server_state.event_queue.clone()); + let bitfun_app_server = app_server::build(agent_runtime, event_source); + + tracing::info!( + "App-server ready; each WebSocket connection drives one in-process serve over native JSON-RPC" + ); + let configured_origins = if args.allowed_origins.is_empty() { DEFAULT_ALLOWED_BROWSER_ORIGINS .iter() @@ -139,6 +189,10 @@ async fn main() -> Result<()> { .allow_methods([Method::GET]) .allow_origin(cors_origins), ) + // The BitFunAppServer is cloned per WebSocket connection through an axum + // Extension (cheap Arc clone); each connection spawns its own `serve` + // over a WS-bridged `Lines` transport. + .layer(axum::Extension(bitfun_app_server)) .with_state(app_state); let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); diff --git a/src/apps/server/src/routes/external_sources.rs b/src/apps/server/src/routes/external_sources.rs index 603aad549..093801e18 100644 --- a/src/apps/server/src/routes/external_sources.rs +++ b/src/apps/server/src/routes/external_sources.rs @@ -6,6 +6,16 @@ use std::path::PathBuf; use crate::AppState; +// NOTE(Step 2a): these host-local external-source dispatch helpers were wired +// through the old `websocket.rs::handle_command` path. Under browser-direct +// ACP-over-WS the browser connects straight to the in-process app-server, so +// `external_sources` commands now hit the ACP `method_not_found` fallback +// (the desktop/Server Host external-source surface is temporarily unavailable +// in web mode -- tracked for a later batch that brings them onto the app-server +// schema). Kept here so the host capability plumbing stays intact for that +// follow-up; silenced as dead code in the meantime. + +#[allow(dead_code)] pub(crate) fn supports(method: &str) -> bool { matches!( method, @@ -25,6 +35,7 @@ pub(crate) fn supports(method: &str) -> bool { ) } +#[allow(dead_code)] pub(crate) async fn dispatch( method: &str, params: serde_json::Value, @@ -76,6 +87,7 @@ pub(crate) async fn dispatch( }) } +#[allow(dead_code)] fn external_workspace_root( state: &AppState, request: &serde_json::Value, @@ -119,6 +131,7 @@ fn external_workspace_root( Ok(Some(requested)) } +#[allow(dead_code)] fn optional_bool_field( request: &serde_json::Value, key: &str, diff --git a/src/apps/server/src/routes/mod.rs b/src/apps/server/src/routes/mod.rs index 0a6d320f4..4376b8744 100644 --- a/src/apps/server/src/routes/mod.rs +++ b/src/apps/server/src/routes/mod.rs @@ -5,3 +5,4 @@ pub(crate) mod external_sources; /// /// Contains all HTTP and WebSocket routes pub(crate) mod websocket; +pub(crate) mod ws_transport; diff --git a/src/apps/server/src/routes/websocket.rs b/src/apps/server/src/routes/websocket.rs index 3c6cfa9c9..e4c262934 100644 --- a/src/apps/server/src/routes/websocket.rs +++ b/src/apps/server/src/routes/websocket.rs @@ -1,64 +1,52 @@ -use anyhow::Result; -/// WebSocket handler -/// -/// Implements real-time bidirectional communication with frontend: -/// - Command request/response (JSON RPC format) -/// - Event push (streaming output, tool calls, etc.) +//! WebSocket handler. +//! +//! Under browser-direct ACP-over-WS (Step 2), the browser speaks raw JSON-RPC +//! 2.0 over the WebSocket. Each connection is handed straight to +//! [`bitfun_app_server::BitfunAppServer::serve`] via the [`super::ws_transport`] +//! `Lines` adapter -- no custom `{type:"request"|...}` envelope, no +//! `route_agent_command`, no shared in-process client. The browser connects +//! directly to the in-process app-server over native JSON-RPC; runtime and +//! permission events are projected to the frontend shape (`agentic://`, +//! `permission://event`) and pushed by the `serve` main loop as +//! `agent/frontendEvent` notifications. +//! +//! # Threat model (single-user local mode) +//! +//! This server targets a single-user, local-only deployment: one developer's +//! browser connecting to a loopback Server Host. There is **no per-connection +//! authentication, token exchange, or workspace/user/execution-domain binding** +//! yet. Every accepted connection can access the full agent kernel control +//! plane (sessions, turns, permissions, config, git). This is acceptable only +//! because the origin allow-list is fail-closed and the server is expected to +//! bind loopback. Multi-user, remote, or untrusted-network deployments require +//! connection-level authentication and scoped authorization that are **not** +//! implemented in this PR. + use axum::{ extract::{ - ws::{Message, WebSocket, WebSocketUpgrade}, - State, + ws::{WebSocket, WebSocketUpgrade}, + Extension, State, }, http::{header::ORIGIN, HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; -use futures_util::{SinkExt, StreamExt}; -use serde::{Deserialize, Serialize}; + +use bitfun_app_server::BitfunAppServer; use crate::AppState; +/// Maximum accepted WS frame size (256 KiB), matching the prior envelope handler. const MAX_WS_TEXT_BYTES: usize = 256 * 1024; -/// WebSocket message protocol (JSON RPC 2.0 style) -#[derive(Debug, Deserialize, Serialize)] -#[serde(tag = "type")] -enum WsMessage { - /// Request message - #[serde(rename = "request")] - Request { - id: String, - method: String, - params: serde_json::Value, - }, - /// Response message - #[serde(rename = "response")] - Response { - id: String, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - }, - /// Event message (no response required) - #[serde(rename = "event")] - Event { - event: String, - payload: serde_json::Value, - }, -} - -#[derive(Debug, Deserialize, Serialize)] -struct ErrorInfo { - code: i32, - message: String, - #[serde(skip_serializing_if = "Option::is_none")] - data: Option, -} - -/// WebSocket connection handler +/// WebSocket connection handler. +/// +/// Validates the browser origin, then upgrades the connection and runs one +/// in-process `BitfunAppServer::serve` per connection over the WS-bridged +/// `Lines` transport. pub(crate) async fn websocket_handler( ws: WebSocketUpgrade, State(state): State, + Extension(bitfun_app_server): Extension, headers: HeaderMap, ) -> Response { if !browser_origin_allowed(&headers, &state) { @@ -68,12 +56,19 @@ pub(crate) async fn websocket_handler( tracing::info!("New WebSocket connection"); ws.max_message_size(MAX_WS_TEXT_BYTES) .max_frame_size(MAX_WS_TEXT_BYTES) - .on_upgrade(|socket| handle_socket(socket, state)) + .on_upgrade(move |socket| handle_socket(socket, bitfun_app_server)) } +/// Check the browser `Origin` header against the allow-list. +/// +/// **Fail-closed**: a missing or unparsable `Origin` header is rejected. This +/// prevents non-browser clients (which do not send `Origin`) from silently +/// accessing the full runtime control plane. Only exact allow-list matches +/// pass. See the module-level threat-model note for the single-user local +/// deployment assumption. fn browser_origin_allowed(headers: &HeaderMap, state: &AppState) -> bool { let Some(origin) = headers.get(ORIGIN) else { - return true; + return false; }; let Ok(origin) = origin.to_str() else { return false; @@ -82,199 +77,29 @@ fn browser_origin_allowed(headers: &HeaderMap, state: &AppState) -> bool { .is_ok_and(|origin| state.allowed_browser_origins.contains(&origin)) } -/// Handle a single WebSocket connection -async fn handle_socket(socket: WebSocket, state: AppState) { - let (mut sender, mut receiver) = socket.split(); - +/// Run one in-process app-server over the WebSocket for the connection's life. +/// +/// `BitfunAppServer` is `Clone` (cheap Arc clone), so each connection gets its +/// own `serve` task; the shared `AgentRuntime` is internally synchronized, and +/// each connection subscribes independently to the runtime event/permission +/// streams. The task ends when the WS transport closes. +async fn handle_socket(socket: WebSocket, bitfun_app_server: BitfunAppServer) { tracing::info!("WebSocket connection established"); - - let welcome_msg = WsMessage::Event { - event: "connection_established".to_string(), - payload: serde_json::json!({ - "server": "BitFun Server", - "version": env!("CARGO_PKG_VERSION"), - "timestamp": chrono::Utc::now().timestamp(), - }), - }; - - if let Ok(json) = serde_json::to_string(&welcome_msg) { - let _ = sender.send(Message::Text(json.into())).await; - } - - while let Some(msg) = receiver.next().await { - match msg { - Ok(Message::Text(text)) => { - if text.len() > MAX_WS_TEXT_BYTES { - tracing::warn!( - message_bytes = text.len(), - "Rejected oversized WebSocket message" - ); - break; - } - if handle_text_message(&mut sender, &text, &state) - .await - .is_err() - { - tracing::warn!( - error_category = "message_processing", - "Failed to handle WebSocket message" - ); - } - } - Ok(Message::Binary(data)) => { - tracing::warn!( - message_bytes = data.len(), - "Rejected unsupported binary WebSocket message" - ); - break; - } - Ok(Message::Ping(data)) => { - tracing::trace!("Received Ping"); - let _ = sender.send(Message::Pong(data)).await; - } - Ok(Message::Pong(_)) => { - tracing::trace!("Received Pong"); - } - Ok(Message::Close(_)) => { - tracing::info!("Client closed connection"); - break; - } - Err(_) => { - tracing::warn!(error_category = "transport", "WebSocket connection failed"); - break; - } - } - } - - tracing::info!("WebSocket connection closed"); -} - -/// Handle text message -async fn handle_text_message( - sender: &mut futures_util::stream::SplitSink, - text: &str, - state: &AppState, -) -> Result<()> { - let ws_msg: WsMessage = serde_json::from_str(text)?; - - match ws_msg { - WsMessage::Request { id, method, params } => { - tracing::info!( - method = safe_protocol_token(&method), - id_kind = "string", - "Handling WebSocket request" - ); - - let result = handle_command(&method, params, state).await; - - let response = match result { - Ok(data) => WsMessage::Response { - id, - result: Some(data), - error: None, - }, - Err(error) => WsMessage::Response { - id, - result: None, - error: Some(ErrorInfo { - code: json_rpc_error_code(error.code), - message: error.detail.clone(), - data: serde_json::to_value(error).ok(), - }), - }, - }; - - let json = serde_json::to_string(&response)?; - sender.send(Message::Text(json.into())).await?; - } - WsMessage::Event { event, .. } => { - tracing::debug!( - event = safe_protocol_token(&event), - "Received WebSocket event" - ); - } - WsMessage::Response { .. } => { - tracing::warn!("Received response message (client should not send responses)"); - } - } - - Ok(()) -} - -/// Handle specific commands -async fn handle_command( - method: &str, - params: serde_json::Value, - state: &AppState, -) -> bitfun_core::external_sources::ExternalSourceOperationResult { - if super::external_sources::supports(method) { - return super::external_sources::dispatch(method, params, state).await; - } - if super::dispatch::supports(method) { - return super::dispatch::dispatch(method, params, state).await; - } - match method { - "ping" => Ok(serde_json::json!({ - "pong": true, - "timestamp": chrono::Utc::now().timestamp(), - })), - _ => { - tracing::warn!( - method = safe_protocol_token(method), - "Unknown Server Host command" - ); - Err(bitfun_core::external_sources::ExternalSourceOperationError::host_capability_unavailable( - "Unknown Server Host operation", - )) - } - } -} - -fn safe_protocol_token(value: &str) -> &str { - if !value.is_empty() - && value.len() <= 64 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) - { - value - } else { - "" - } -} - -fn json_rpc_error_code( - code: bitfun_core::external_sources::ExternalSourceOperationErrorCode, -) -> i32 { - use bitfun_core::external_sources::ExternalSourceOperationErrorCode; - match code { - ExternalSourceOperationErrorCode::InvalidRequest => -32602, - ExternalSourceOperationErrorCode::HostCapabilityUnavailable => -32601, - ExternalSourceOperationErrorCode::StaleRevision - | ExternalSourceOperationErrorCode::Conflict => -32009, - ExternalSourceOperationErrorCode::HostUnavailable - | ExternalSourceOperationErrorCode::Unavailable - | ExternalSourceOperationErrorCode::RuntimeUnavailable - | ExternalSourceOperationErrorCode::DependencyFailed - | ExternalSourceOperationErrorCode::Timeout - | ExternalSourceOperationErrorCode::Overloaded - | ExternalSourceOperationErrorCode::ProcessLost - | ExternalSourceOperationErrorCode::TemporarilyUnavailable => -32003, - ExternalSourceOperationErrorCode::Cancelled => -32800, - ExternalSourceOperationErrorCode::TrustRequired - | ExternalSourceOperationErrorCode::PolicyIncompatible - | ExternalSourceOperationErrorCode::PolicyLimited - | ExternalSourceOperationErrorCode::Unsupported - | ExternalSourceOperationErrorCode::IncompatibleVersion => -32010, - ExternalSourceOperationErrorCode::NotFound => -32004, - ExternalSourceOperationErrorCode::InvalidResponse - | ExternalSourceOperationErrorCode::Internal => -32603, + let lines = super::ws_transport::ws_lines(socket); + let result = bitfun_app_server.serve(lines).await; + match &result { + Ok(()) => tracing::info!("WebSocket app-server connection ended cleanly"), + Err(error) => tracing::warn!( + error = ?error, + "WebSocket app-server connection ended with error" + ), } } #[cfg(test)] mod tests { use super::*; + use axum::http::HeaderMap; fn state_with_allowed_origins(origins: &[&str]) -> AppState { AppState { @@ -296,34 +121,7 @@ mod tests { let mut unknown_headers = HeaderMap::new(); unknown_headers.insert(ORIGIN, "https://example.test".parse().unwrap()); assert!(!browser_origin_allowed(&unknown_headers, &state)); - assert!(browser_origin_allowed(&HeaderMap::new(), &state)); - } - - #[test] - fn typed_errors_keep_stable_json_rpc_categories() { - use bitfun_core::external_sources::ExternalSourceOperationErrorCode; - - assert_eq!( - json_rpc_error_code(ExternalSourceOperationErrorCode::InvalidRequest), - -32602 - ); - assert_eq!( - json_rpc_error_code(ExternalSourceOperationErrorCode::HostCapabilityUnavailable), - -32601 - ); - assert_eq!( - json_rpc_error_code(ExternalSourceOperationErrorCode::PolicyLimited), - -32010 - ); - } - - #[test] - fn client_protocol_tokens_are_bounded_before_logging() { - assert_eq!( - safe_protocol_token("get_external_source_snapshot"), - "get_external_source_snapshot" - ); - assert_eq!(safe_protocol_token("method\nsecret"), ""); - assert_eq!(safe_protocol_token(&"x".repeat(65)), ""); + // Missing Origin must be rejected (fail-closed). + assert!(!browser_origin_allowed(&HeaderMap::new(), &state)); } } diff --git a/src/apps/server/src/routes/ws_transport.rs b/src/apps/server/src/routes/ws_transport.rs new file mode 100644 index 000000000..81573a4a0 --- /dev/null +++ b/src/apps/server/src/routes/ws_transport.rs @@ -0,0 +1,109 @@ +//! Axum WebSocket -> `agent_client_protocol::Lines` transport bridge. +//! +//! The browser speaks raw JSON-RPC 2.0 over WebSocket (one message per WS text +//! frame). ACP's [`agent_client_protocol::Lines`] already implements +//! [`agent_client_protocol::ConnectTo`] for any `futures::Sink` + `futures::Stream>` pair. This module +//! adapts an axum `WebSocket` (after `split()`) into exactly that pair: outgoing +//! wraps the `SplitSink` (each `String` -> `Message::Text`), incoming wraps the +//! `SplitStream` (each `Message::Text` -> `Ok(String)`, everything else -> `Err`). +//! +//! The returned `Lines` is handed to [`bitfun_app_server::BitfunAppServer::serve`] +//! per WebSocket connection, so the browser connects directly to the in-process +//! app-server over native JSON-RPC -- no custom `{type:"request"|...}` envelope, +//! no hand-written `route_agent_command`, no shared in-process client. + +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use agent_client_protocol::Lines; +use axum::extract::ws::{Message, WebSocket}; +use futures::{Sink, Stream}; +use futures_util::stream::{SplitSink, SplitStream}; +use futures_util::{SinkExt, StreamExt}; + +/// Bridge an axum WebSocket into an ACP `Lines` transport for +/// `BitfunAppServer::serve(lines)`. +/// +/// The WebSocket is split; the outgoing half becomes the `Lines` sink (one +/// `String` per WS text frame), the incoming half becomes the stream (text +/// frames only; binary/control frames surface as a stream end or `io::Error`). +pub(crate) fn ws_lines(socket: WebSocket) -> Lines { + let (sink, stream) = socket.split(); + Lines::new(WSSink { sink }, WSStream { stream }) +} + +/// Outgoing adapter: `futures::Sink` -> axum +/// `Message::Text`. The inner `SplitSink` is `Unpin` (axum's `WebSocket` wraps a +/// tungstenite `WebSocketStream`), so we mark this wrapper `Unpin` too and +/// project through `Pin::new` without unsafe. +pub(crate) struct WSSink { + sink: SplitSink, +} + +// SAFETY: `SplitSink` is `Unpin` (tungstenite stream-backed), +// so this wrapper is too. +impl Unpin for WSSink {} + +impl Sink for WSSink { + type Error = io::Error; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + Sink::poll_ready(Pin::new(&mut this.sink), cx) + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "ws ready failed")) + } + + fn start_send(self: Pin<&mut Self>, item: String) -> Result<(), Self::Error> { + let this = self.get_mut(); + this.sink + .start_send_unpin(Message::Text(item.into())) + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "ws send failed")) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + Sink::poll_flush(Pin::new(&mut this.sink), cx) + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "ws flush failed")) + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + Sink::poll_close(Pin::new(&mut this.sink), cx) + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "ws close failed")) + } +} + +/// Incoming adapter: `futures::Stream>` from axum +/// `Message::Text`. Binary frames surface as `io::Error`; control frames close +/// the stream (axum handles ping/pong internally). +pub(crate) struct WSStream { + stream: SplitStream, +} + +impl Unpin for WSStream {} + +impl Stream for WSStream { + type Item = io::Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + match Stream::poll_next(Pin::new(&mut this.stream), cx) { + Poll::Ready(None) => Poll::Ready(None), + Poll::Ready(Some(Err(_))) => Poll::Ready(Some(Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + "ws recv failed", + )))), + Poll::Ready(Some(Ok(Message::Text(text)))) => Poll::Ready(Some(Ok(text.to_string()))), + Poll::Ready(Some(Ok(Message::Binary(_)))) => Poll::Ready(Some(Err(io::Error::new( + io::ErrorKind::InvalidData, + "binary ws frames not supported", + )))), + Poll::Ready(Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Close(_)))) => { + Poll::Ready(None) + } + Poll::Pending => Poll::Pending, + } + } +} diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 4aab1a7f0..75764bc2f 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -20,6 +20,7 @@ futures = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } +ts-rs = { workspace = true, optional = true } anyhow = { workspace = true } thiserror = { workspace = true } @@ -166,6 +167,22 @@ schannel = "0.1" # Full product runtime feature set. Product crates should depend on this # explicitly before `bitfun-core` default features are made lighter. default = ["product-full"] +# Derive `ts_rs::TS` on selected wire types (e.g. `AgentProfileView`) for +# downstream TypeScript binding export. Propagates `ts` to the upstream crates +# whose types core re-exports (git types live in `services-integrations`, +# permission/session types in `runtime-ports`/`product-domains`). The weak +# `bitfun-product-domains?/ts` reference adds the TS derive only when the +# `product-domains` feature group is already enabled, so `ts` stays orthogonal +# to the product matrix and never pulls the optional product-domain dependency +# on its own. The other two upstream crates are non-optional (always compiled), +# so their `ts` features propagate directly. +ts = [ + "dep:ts-rs", + "bitfun-core-types/ts", + "bitfun-services-integrations/ts", + "bitfun-product-domains?/ts", + "bitfun-runtime-ports/ts", +] product-full = [ "ai-adapter-runtime", "canvas-runtime", diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 134c741b4..a82b17911 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -999,6 +999,7 @@ pub struct SkillSettingsConfig { /// API view of a mode configuration. #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(default)] pub struct AgentProfileView { pub profile_id: String, diff --git a/src/crates/contracts/core-types/Cargo.toml b/src/crates/contracts/core-types/Cargo.toml index ab3ebf3e9..22486a953 100644 --- a/src/crates/contracts/core-types/Cargo.toml +++ b/src/crates/contracts/core-types/Cargo.toml @@ -11,6 +11,15 @@ crate-type = ["rlib"] [dependencies] serde = { workspace = true } serde_json = { workspace = true } +ts-rs = { workspace = true, optional = true } + +[features] +default = [] +# Derive `ts_rs::TS` on the wire types so downstream crates (runtime-ports, +# app-server) can export TypeScript bindings for types that reference them. +# Orthogonal to the existing serde contract; pulls no extra integration +# dependency. Mirrors the `ts` feature in `bitfun-product-domains`. +ts = ["dep:ts-rs"] [lints] workspace = true diff --git a/src/crates/contracts/core-types/src/worktree.rs b/src/crates/contracts/core-types/src/worktree.rs index 457d2cea3..84e5f343e 100644 --- a/src/crates/contracts/core-types/src/worktree.rs +++ b/src/crates/contracts/core-types/src/worktree.rs @@ -26,6 +26,7 @@ pub enum SessionExecutionTargetRequest { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub enum SessionExecutionTargetKind { #[default] @@ -35,6 +36,7 @@ pub enum SessionExecutionTargetKind { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub enum WorktreeLifecycle { Managed, @@ -44,6 +46,7 @@ pub enum WorktreeLifecycle { /// Resolved and persisted execution location for a session. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct SessionExecutionTarget { pub kind: SessionExecutionTargetKind, diff --git a/src/crates/contracts/product-domains/Cargo.toml b/src/crates/contracts/product-domains/Cargo.toml index 9863445f8..6108be35f 100644 --- a/src/crates/contracts/product-domains/Cargo.toml +++ b/src/crates/contracts/product-domains/Cargo.toml @@ -38,6 +38,7 @@ sha2 = { workspace = true, optional = true } hex = { workspace = true, optional = true } hmac = { workspace = true, optional = true } which = { workspace = true, optional = true } +ts-rs = { workspace = true, optional = true } [features] default = [] @@ -46,6 +47,10 @@ miniapp = ["dirs", "sha2", "which"] function-agents = ["log"] external-sources = ["hex", "hmac", "sha2"] product-full = ["plugin-source", "miniapp", "function-agents", "external-sources"] +# Derive `ts_rs::TS` on the wire types (permissions, etc.) for downstream +# TypeScript binding export. Orthogonal to the product feature groups; does not +# pull any optional integration dependency. +ts = ["dep:ts-rs"] [dev-dependencies] tokio = { workspace = true } diff --git a/src/crates/contracts/product-domains/src/tool_permissions.rs b/src/crates/contracts/product-domains/src/tool_permissions.rs index 9a60dd91d..34e2c695d 100644 --- a/src/crates/contracts/product-domains/src/tool_permissions.rs +++ b/src/crates/contracts/product-domains/src/tool_permissions.rs @@ -231,6 +231,7 @@ pub fn resolve_child_permission_policy( /// Identifies the boundary that originated a permission request. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "snake_case")] pub enum PermissionRequestSourceKind { ToolCall, @@ -239,6 +240,7 @@ pub enum PermissionRequestSourceKind { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct PermissionRequestSource { pub kind: PermissionRequestSourceKind, @@ -252,6 +254,7 @@ pub struct PermissionRequestSource { /// concrete child execution. These fields only project the existing /// delegation relationship to interactive surfaces and audit consumers. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct PermissionDelegationContext { pub parent_session_id: String, @@ -270,6 +273,7 @@ pub struct PermissionDelegationContext { /// presentation and audit persistence. Raw secrets and unrestricted command /// payloads must remain outside this DTO. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct PermissionRequest { pub request_id: String, @@ -314,6 +318,7 @@ pub struct PermissionRequest { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(tag = "reply", rename_all = "snake_case")] pub enum PermissionReply { Once, @@ -325,6 +330,7 @@ pub enum PermissionReply { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "snake_case")] pub enum PermissionReplySource { User, @@ -334,6 +340,7 @@ pub enum PermissionReplySource { /// Process-local lifecycle event projected to interactive permission surfaces. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde( tag = "event", rename_all = "snake_case", @@ -356,6 +363,7 @@ pub enum PermissionRequestEvent { /// A remembered allow scoped by project, action, and resource. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct PermissionGrant { pub project_id: String, @@ -375,6 +383,7 @@ impl PermissionGrant { } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct PermissionGrantKey { pub project_id: String, @@ -383,6 +392,7 @@ pub struct PermissionGrantKey { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(tag = "event", rename_all = "snake_case")] pub enum PermissionAuditEvent { Requested, @@ -397,6 +407,7 @@ pub enum PermissionAuditEvent { /// An append-only audit fact containing only presentation-safe request data. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct PermissionAuditRecord { pub audit_id: String, diff --git a/src/crates/contracts/runtime-ports/Cargo.toml b/src/crates/contracts/runtime-ports/Cargo.toml index 4d9d67f97..9f2fa6720 100644 --- a/src/crates/contracts/runtime-ports/Cargo.toml +++ b/src/crates/contracts/runtime-ports/Cargo.toml @@ -18,10 +18,24 @@ serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } +ts-rs = { workspace = true, optional = true } [features] default = [] permission = ["dep:bitfun-product-domains"] +# Derive `ts_rs::TS` on the wire types so downstream crates (app-server) can +# export TypeScript bindings. The `permission` feature gates the product +# types; enabling `ts` requires `permission` so all TS-derivable types are in +# scope. `bitfun-product-domains?/ts` is a weak feature reference: it adds the +# TS derive to the product-domain types only when `bitfun-product-domains` is +# already enabled (always true here via `permission`), so `ts` cannot become an +# undeclared owner of the optional product-domain dependency. +ts = [ + "dep:ts-rs", + "permission", + "bitfun-core-types/ts", + "bitfun-product-domains?/ts", +] [dev-dependencies] tokio = { workspace = true } diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 68c4ed224..a12264311 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -967,6 +967,7 @@ impl RemoteProjectionPort for T where pub trait RemoteCapabilityPort: RuntimeServicePort {} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionCreateRequest { pub session_name: String, @@ -990,6 +991,7 @@ pub struct AgentSessionCreateRequest { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionCreateResult { pub session_id: String, @@ -1025,6 +1027,7 @@ impl AgentSessionCreateResult { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionListRequest { pub workspace_path: String, @@ -1035,6 +1038,7 @@ pub struct AgentSessionListRequest { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionSummary { pub session_id: String, @@ -1053,6 +1057,7 @@ pub struct AgentSessionSummary { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionDeleteRequest { pub workspace_path: String, @@ -1213,6 +1218,7 @@ pub struct AgentSessionWorkspaceBinding { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSubmissionRequest { pub session_id: String, @@ -1302,6 +1308,7 @@ pub struct AgentThreadGoalDeliveryRequest { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "snake_case")] pub enum AgentSubmissionSource { DesktopUi, @@ -1317,6 +1324,7 @@ pub enum AgentSubmissionSource { pub type DialogTriggerSource = AgentSubmissionSource; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "snake_case")] pub enum DialogQueuePriority { Low = 0, @@ -1325,6 +1333,7 @@ pub enum DialogQueuePriority { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct DialogSubmissionPolicy { pub trigger_source: DialogTriggerSource, @@ -1777,6 +1786,7 @@ pub struct RelatedPath { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentInputAttachment { pub kind: String, @@ -1807,6 +1817,7 @@ impl AgentInputAttachment { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSubmissionResult { pub turn_id: String, @@ -2034,6 +2045,7 @@ pub trait AgentThreadGoalManagementPort: Send + Sync { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentTurnCancellationRequest { pub session_id: String, @@ -2050,6 +2062,7 @@ pub struct AgentTurnCancellationRequest { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentTurnCancellationResult { pub session_id: String, diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index 86f7d4825..a3ebcca18 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -53,6 +53,11 @@ pub use crate::runtime::{ pub use crate::session_state::{session_state_label_for_state, ProcessingPhase, SessionState}; pub use bitfun_agent_tools::{ToolRegistry, ToolRegistryItem}; pub use bitfun_core_types::SessionUsageReport; +// Event envelope types re-exported so protocol surfaces (e.g. `bitfun-app-server`) +// can carry the runtime event stream over a JSON-RPC transport without depending +// on `bitfun-events` directly. These are the exact types the runtime's event +// subscribers receive; the app-server forwards them as `agent/event` notifications. +pub use bitfun_events::{AgenticEvent, AgenticEventEnvelope}; pub use bitfun_harness::{ build_descriptor_harness_registry, HarnessCapability, HarnessProviderDescriptor, HarnessRegistry, HarnessWorkflow, diff --git a/src/crates/interfaces/AGENTS.md b/src/crates/interfaces/AGENTS.md index 663a667f5..231ecea2c 100644 --- a/src/crates/interfaces/AGENTS.md +++ b/src/crates/interfaces/AGENTS.md @@ -12,6 +12,7 @@ product behavior. UI apps and delivery hosts remain under `src/apps`, | Crate | Responsibility | Local doc | |---|---|---| | `acp` | Agent Client Protocol interface over the assembled product runtime | [AGENTS.md](acp/AGENTS.md) | +| `app-server` | Protocol-agnostic JSON-RPC app-server/client scaffold over `agent_client_protocol` custom roles | [AGENTS.md](app-server/AGENTS.md) | | `sdk-host` | Versioned local Agent SDK Host protocol and connection use cases; process bootstrap and stdio framing remain in `src/apps/sdk-host` | — | ## Placement Rules diff --git a/src/crates/interfaces/app-server/AGENTS-CN.md b/src/crates/interfaces/app-server/AGENTS-CN.md new file mode 100644 index 000000000..18ec43ecb --- /dev/null +++ b/src/crates/interfaces/app-server/AGENTS-CN.md @@ -0,0 +1,73 @@ +**中文** | [English](AGENTS.md) + +# 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` 角色不同)。 + +## 护栏 + +- 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` 回复。 + +## 事件投递 + +Runtime 事件属于 app-server 协议接口,而非 host 侧订阅。流程在 transport 上是 +单向的: + +- **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"的契约。 + +## 验证(续) + +在边界将 `RuntimeError` 映射为 JSON-RPC `Error`(见 +`BitfunAppRuntime::runtime_error` / `session_runtime_error`);不要通过 wire 泄露 +runtime 内部细节。 + +## 验证 + +```bash +cargo check -p bitfun-app-server --offline +cargo test -p bitfun-app-server --offline +``` diff --git a/src/crates/interfaces/app-server/AGENTS.md b/src/crates/interfaces/app-server/AGENTS.md new file mode 100644 index 000000000..2e852a693 --- /dev/null +++ b/src/crates/interfaces/app-server/AGENTS.md @@ -0,0 +1,83 @@ +[中文](AGENTS-CN.md) | **English** + +# App-Server Protocol Surface Guide + +Scope: this guide applies to `src/crates/interfaces/app-server`. + +`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. + +## 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 + +Runtime events are part of the app-server protocol surface, not a host-side +subscription. The flow is one-directional over the transport: + +- 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. + +## Verification (continued) + +Map `RuntimeError` to JSON-RPC `Error` at the boundary (see +`BitfunAppRuntime::runtime_error` / `session_runtime_error`); do not leak +runtime internals through the wire. + +## Verification + +```bash +cargo check -p bitfun-app-server --offline +cargo test -p bitfun-app-server --offline +``` diff --git a/src/crates/interfaces/app-server/Cargo.toml b/src/crates/interfaces/app-server/Cargo.toml new file mode 100644 index 000000000..465f2a599 --- /dev/null +++ b/src/crates/interfaces/app-server/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "bitfun-app-server" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "BitFun generic JSON-RPC app-server surface over agent_client_protocol custom roles" + +[lib] +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-agent-runtime = { path = "../../execution/agent-runtime" } +bitfun-events = { path = "../../contracts/events" } +tokio = { workspace = true } +tokio-util = { workspace = true, features = ["compat"] } +futures = { workspace = true } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +log = { workspace = true } +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 +# 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"] + +[lints] +workspace = true diff --git a/src/crates/interfaces/app-server/src/agent.rs b/src/crates/interfaces/app-server/src/agent.rs new file mode 100644 index 000000000..6ba127226 --- /dev/null +++ b/src/crates/interfaces/app-server/src/agent.rs @@ -0,0 +1,199 @@ +//! BitFun agent runtime adapter for the generic app-server surface. +//! +//! This is the Phase 2 wiring point: [`BitfunAppRuntime`] holds an externally +//! assembled [`AgentRuntime`] (built by product assembly from `AgenticSystem`, +//! the same way `bitfun_acp::BitfunAcpRuntime` receives its runtime) and exposes +//! the agent kernel operations the JSON-RPC handlers delegate to. Like the ACP +//! runtime boundary, this crate does not own product assembly or the +//! compatibility facade; the host injects a ready runtime. + +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; + +/// Host-injected BitFun agent runtime exposed over the app-server surface. +/// +/// Construct with [`BitfunAppRuntime::new`] from a product-assembled +/// `AgentRuntime` and the runtime's [`AgentEventSource`] (so the server can +/// forward runtime events to the client as `agent/event` notifications), then +/// pass a clone to [`crate::server::BitfunAppServer::new`]. +#[derive(Clone)] +pub struct BitfunAppRuntime { + runtime: Arc, + event_source: AgentEventSource, +} + +impl std::fmt::Debug for BitfunAppRuntime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BitfunAppRuntime").finish_non_exhaustive() + } +} + +impl BitfunAppRuntime { + /// Wrap an externally assembled agent runtime together with the runtime + /// event source the server uses to forward `agent/event` notifications. + /// Both are shared by reference across handlers, so callers usually pass a + /// freshly built `AgentRuntime` and the matching `AgentEventSource` + /// (constructed from the same `EventQueue` the runtime publishes to). + pub fn new(runtime: AgentRuntime, event_source: AgentEventSource) -> Self { + Self { + runtime: Arc::new(runtime), + event_source, + } + } + + /// 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 { + &self.runtime + } + + /// Clone of the injected runtime event source. The server's event + /// forwarder subscribes through this handle to drain runtime events and + /// push them to the client as `agent/event` notifications over the + /// transport. Keeping this on the server side means the client never + /// subscribes to the runtime queue directly. + pub fn event_source(&self) -> AgentEventSource { + self.event_source.clone() + } + + /// 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 + /// `internal_error` with the message surfaced as data. + pub fn runtime_error(error: RuntimeError) -> Error { + match error { + RuntimeError::Port(error) => match error.kind { + PortErrorKind::InvalidRequest => Error::invalid_params().data(error.message), + PortErrorKind::NotFound => Error::resource_not_found(None), + _ => Self::internal_error(error.message), + }, + other => Self::internal_error(other.into_message()), + } + } + + /// Map a `RuntimeError` that carries a session id, so the resource id is + /// surfaced on the `resource_not_found` response. + pub fn session_runtime_error(session_id: &str, error: RuntimeError) -> Error { + match error { + RuntimeError::Port(error) if error.kind == PortErrorKind::NotFound => { + Error::resource_not_found(Some(session_id.to_string())) + } + other => Self::runtime_error(other), + } + } + + fn internal_error(message: impl std::fmt::Display) -> Error { + Error::internal_error().data(serde_json::json!(message.to_string())) + } +} + +/// Convenience for wrapping a fallible runtime call into a JSON-RPC `Result`. +pub fn runtime_call(result: std::result::Result) -> Result { + result.map_err(BitfunAppRuntime::runtime_error) +} + +/// Map a `bitfun_core::service::git::GitError` onto a JSON-RPC `Error`. Mirrors +/// the runtime boundary's intent: argument/path problems surface as +/// `invalid_params`, everything else as `internal_error` with the message in +/// `data`. Used by the `git/*` schema handlers. +pub fn git_service_error(error: GitError) -> Error { + match error { + GitError::RepositoryNotFound(_) + | GitError::InvalidPath(_) + | GitError::BranchNotFound(_) => { + Error::invalid_params().data(serde_json::json!(error.to_string())) + } + other => Error::internal_error().data(serde_json::json!(other.to_string())), + } +} + +/// Map a `bitfun_core::BitFunError` (the unified core error type) onto a +/// JSON-RPC `Error`. `BitFunError` already derives `Serialize`, so the whole +/// enum is attached as `data` (carrying the variant + message) for the client +/// to inspect; the human-readable `Display` form goes into the message. Used +/// by host-service handlers (`config/*`, and the upcoming `workspace/*`, +/// `snapshot/*` batches) that call into `bitfun-core` service singletons. +pub fn bitfun_error(error: bitfun_core::BitFunError) -> Error { + Error::internal_error().data(serde_json::to_value(&error).unwrap_or(serde_json::Value::Null)) +} + +/// Map a `BitFunError` from a `config/getConfig` / `config/getConfigs` call +/// onto a JSON-RPC `Error`. +/// +/// The frontend `ConfigAPI.getConfig` swallows the error and returns +/// `undefined` only when `error.message` (lowercased) contains the substrings +/// `not found:`, `config path`, and `''` -- it does not inspect `data`. +/// [`bitfun_error`] leaves `message` as the static `"Internal error"`, so the +/// substring match never hits on a path-not-found. This helper instead puts +/// the human-readable `BitFunError` Display text into the JSON-RPC `message` +/// for the `NotFound` case, mirroring the desktop host's +/// `"Failed to get config: Not found: Config path '' not found"` shape +/// (`desktop/src/api/config_api.rs` + `BitFunError::NotFound` Display = +/// `Not found: {0}`) byte-for-byte, so the frontend substring match works in +/// web mode the same way it does on desktop. The structured `BitFunError` is +/// still attached as `data` for callers that inspect it. Other config errors +/// fall back to the generic [`bitfun_error`] shape. +pub fn config_get_error(error: bitfun_core::BitFunError) -> Error { + match &error { + bitfun_core::BitFunError::NotFound(_) => Error::new( + agent_client_protocol::ErrorCode::InternalError.into(), + format!("Failed to get config: {}", error), + ) + .data(serde_json::to_value(&error).unwrap_or(serde_json::Value::Null)), + _ => bitfun_error(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The frontend `ConfigAPI.getConfig` swallows the error into `undefined` + /// only when `error.message` (lowercased) contains `not found:`, `config + /// path`, and `''`. Pin the exact substrings the desktop host + /// produces so a future refactor of `config_get_error` cannot silently + /// break the frontend swallow in web mode. + #[test] + fn config_get_error_not_found_message_matches_frontend_substrings() { + let path = "ai.review_teams.default"; + let error = bitfun_core::BitFunError::NotFound(format!("Config path '{}' not found", path)); + let mapped = config_get_error(error); + + let message = mapped.message.to_lowercase(); + assert!( + message.contains("not found:"), + "message must contain 'not found:' for the frontend match, got: {mapped:?}" + ); + assert!( + message.contains("config path"), + "message must contain 'config path' for the frontend match, got: {mapped:?}" + ); + assert!( + message.contains(&format!("'{}'", path)), + "message must contain the quoted path for the frontend match, got: {mapped:?}" + ); + // The structured enum still rides along in `data` for callers that + // inspect it. + assert!(mapped.data.is_some(), "data must carry the BitFunError enum"); + } + + /// Non-`NotFound` config errors must fall back to the generic `bitfun_error` + /// shape (`message = "Internal error"`, structured enum in `data`), so they + /// are NOT swallowed by the frontend substring match -- they surface as + /// real errors. + #[test] + fn config_get_error_non_not_found_falls_back_to_internal_error_message() { + let error = bitfun_core::BitFunError::config("something else broke"); + let mapped = config_get_error(error); + + assert_eq!( + 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 new file mode 100644 index 000000000..99ee3380c --- /dev/null +++ b/src/crates/interfaces/app-server/src/client.rs @@ -0,0 +1,629 @@ +//! Generic app-server client: drives an [`AppClient`] connection over a +//! host-supplied transport and exposes the agent kernel RPC surface. +//! +//! This module is the client counterpart of [`crate::server::BitfunAppServer`]. +//! It is **transport-agnostic**: the host picks the transport (in-memory pair, +//! stdio, websocket, ...) and passes the client half to [`connect`]. The +//! server half is driven by the host through [`BitfunAppServer::serve`], so +//! this client never decides how the server is started -- it only owns the +//! client end of one connection. +//! +//! [`connect`] builds an [`AppClient`] on the given transport, parks the +//! [`ConnectionTo`] for the lifetime of the returned handle, and +//! registers `on_receive_notification` for `agent/event`. Each forwarded +//! [`AgenticEventEnvelope`] is projected to the frontend shape (via +//! [`bitfun_events::project_agentic_frontend_event`]) and fanned out through a +//! [`tokio::sync::broadcast`] channel; consumers receive them via +//! [`AppServerClient::subscribe_events`]. The client never subscribes to the +//! runtime event queue directly, so all agent interfaces uniformly go through +//! the app-server protocol surface. +//! +//! The connection-driving pattern mirrors the ACP client manager +//! (`acp/src/client/manager.rs`): the `connect_with` main function parks the +//! [`ConnectionTo`] through a oneshot and then awaits a shutdown +//! signal so the connection task stays alive for the lifetime of the host. +//! +//! # In-process pairing +//! +//! A host that runs the server and client in the same process pairs them with +//! [`crate::transport::in_memory_channel_pair`]: +//! +//! ```no_run +//! # use bitfun_app_server::{AppClient, AppServer, transport}; +//! # use bitfun_app_server::client::connect; +//! # async fn example() -> anyhow::Result<()> { +//! // let (server_transport, client_transport) = transport::in_memory_channel_pair(); +//! // let _server = tokio::spawn(async { /* serve BitfunAppServer on server_transport */ }); +//! // let client = connect(client_transport).await?; +//! # Ok(()) +//! # } +//! ``` + +use std::sync::Arc; +use std::time::Duration; + +use agent_client_protocol::{ConnectionTo, JsonRpcResponse, Result, SentRequest}; +use agent_client_protocol::ConnectTo; +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, +}; +use crate::{AppClient, AppServer}; + +/// Projected frontend event forwarded to consumers (websocket connections, +/// Tauri event bridge, ...). +#[derive(Debug, Clone)] +pub struct FrontendEvent { + /// Frontend event name (`agent/event` projection). + pub event: String, + /// Projected payload, ready to serialize for the consumer's wire shape. + pub payload: serde_json::Value, +} + +/// Startup grace for the in-process app-server client connection. +const CLIENT_STARTUP_TIMEOUT: Duration = Duration::from_secs(5); + +/// Client handle for an app-server connection. +/// +/// Cheaply cloneable: each clone shares the connection handle, the event +/// broadcast sender, and the shutdown signal. Use [`AppServerClient::rpc`] +/// (or the typed helpers) to route an agent kernel RPC through the app-server +/// surface, and [`AppServerClient::subscribe_events`] to receive projected +/// runtime events. +/// +/// The connection is parked for the lifetime of this handle: the in-process +/// `connect_task` that drives the `AppClient` connection parks on the shutdown +/// receiver, and the `shutdown_tx` lives in the shared handle so the parked +/// main loop only resumes when [`AppServerClient::shutdown`] is called or the +/// last clone is dropped. Hosts that want the connection to live as long as +/// the process (the Server Host pattern) simply hold a clone and never call +/// `shutdown`. +#[derive(Clone)] +pub struct AppServerClient { + connection: Arc>, + event_tx: broadcast::Sender, + shutdown_tx: Arc>>>, +} + +impl AppServerClient { + /// Subscribe to projected frontend events. One receiver per consumer + /// (websocket connection, Tauri bridge, ...); dropping the last receiver + /// does not close the server. + pub fn subscribe_events(&self) -> broadcast::Receiver { + self.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 + /// transport. Safe to call more than once; subsequent calls are no-ops. + /// 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()) { + let _ = tx.send(()); + } + } + + /// Create an agent session via `agent/createSession`. + pub async fn create_session( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionCreateRequest, + ) -> Result { + let CreateSessionResponse(inner) = self + .rpc(|cx| cx.send_request(CreateSessionMessage(request))) + .await?; + Ok(inner) + } + + /// List agent sessions via `agent/listSessions`. + pub async fn list_sessions( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionListRequest, + ) -> Result> { + let ListSessionsResponse { sessions } = self + .rpc(|cx| cx.send_request(ListSessionsMessage(request))) + .await?; + Ok(sessions) + } + + /// Delete an agent session via `agent/deleteSession`. + pub async fn delete_session( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionDeleteRequest, + ) -> Result<()> { + let DeleteSessionResponse {} = self + .rpc(|cx| cx.send_request(DeleteSessionMessage(request))) + .await?; + Ok(()) + } + + /// Submit a turn via `agent/submitTurn`. + pub async fn submit_turn( + &self, + request: bitfun_agent_runtime::sdk::AgentSubmissionRequest, + ) -> Result { + let SubmitTurnResponse(inner) = self + .rpc(|cx| cx.send_request(SubmitTurnMessage(request))) + .await?; + Ok(inner) + } + + /// Submit a dialog turn via `agent/submitDialogTurn`. + /// + /// This is the operation the desktop `start_dialog_turn` command drives and + /// the one web/CLI hosts should map `startDialogTurn`-style calls to: it + /// carries `agentType`/`workspacePath`/`policy`, unlike `submit_turn` which + /// is a bare message into an existing session. The response reports whether + /// the turn started immediately or was queued. + pub async fn submit_dialog_turn( + &self, + body: crate::schema::SubmitDialogTurnBody, + ) -> Result { + self.rpc(|cx| cx.send_request(SubmitDialogTurnMessage(body))) + .await + } + + /// Cancel a turn via `agent/cancelTurn`. + pub async fn cancel_turn( + &self, + request: bitfun_agent_runtime::sdk::AgentTurnCancellationRequest, + ) -> Result { + let CancelTurnResponse(inner) = self + .rpc(|cx| cx.send_request(CancelTurnMessage(request))) + .await?; + Ok(inner) + } + + /// Respond to a permission request via `agent/respondPermission`. + pub async fn respond_permission( + &self, + request_id: &str, + reply: bitfun_agent_runtime::sdk::PermissionReply, + ) -> Result<()> { + let RespondPermissionResponse {} = self + .rpc(|cx| { + cx.send_request(RespondPermissionMessage { + request_id: request_id.to_string(), + reply, + }) + }) + .await?; + Ok(()) + } + + /// Respond to a permission request and all requests sharing its reply via + /// `agent/respondPermissionBatch`. Returns the request ids that shared the + /// resolved reply. + pub async fn respond_permission_batch( + &self, + request_id: &str, + reply: bitfun_agent_runtime::sdk::PermissionReply, + ) -> Result> { + let RespondPermissionBatchResponse { request_ids } = self + .rpc(|cx| { + cx.send_request(RespondPermissionBatchMessage { + request_id: request_id.to_string(), + reply, + }) + }) + .await?; + Ok(request_ids) + } + + /// List pending permission requests via `agent/listPendingPermissionRequests`. + pub async fn list_pending_permission_requests( + &self, + ) -> Result> { + let ListPendingPermissionRequestsResponse { requests } = self + .rpc(|cx| cx.send_request(ListPendingPermissionRequestsMessage {})) + .await?; + Ok(requests) + } + + /// List project permission grants via `agent/listProjectPermissionGrants`. + pub async fn list_project_permission_grants( + &self, + project_id: &str, + ) -> Result> { + let ListProjectPermissionGrantsResponse { grants } = self + .rpc(|cx| { + cx.send_request(ListProjectPermissionGrantsMessage { + project_id: project_id.to_string(), + }) + }) + .await?; + Ok(grants) + } + + /// Remove a project permission grant via `agent/removeProjectPermissionGrant`. + pub async fn remove_project_permission_grant( + &self, + key: bitfun_agent_runtime::sdk::PermissionGrantKey, + ) -> Result { + let RemoveProjectPermissionGrantResponse { removed } = self + .rpc(|cx| cx.send_request(RemoveProjectPermissionGrantMessage(key))) + .await?; + Ok(removed) + } + + /// 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 { + let ClearProjectPermissionGrantsResponse { cleared } = self + .rpc(|cx| { + cx.send_request(ClearProjectPermissionGrantsMessage { + project_id: project_id.to_string(), + }) + }) + .await?; + Ok(cleared) + } + + /// List the permission audit log for a project via + /// `agent/listProjectPermissionAudit`. + pub async fn list_project_permission_audit( + &self, + project_id: &str, + ) -> Result> { + let ListProjectPermissionAuditResponse { records } = self + .rpc(|cx| { + cx.send_request(ListProjectPermissionAuditMessage { + project_id: project_id.to_string(), + }) + }) + .await?; + Ok(records) + } + + /// Run an agent turn (create-or-existing) via `agent/run`. + pub async fn run(&self, request: RunMessage) -> Result { + self.rpc(|cx| cx.send_request(request)).await + } + + /// Check whether a path is a Git repository via `git/isRepository`. + pub async fn git_is_repository(&self, repository_path: &str) -> Result { + let GitIsRepositoryResponse(value) = self + .rpc(|cx| { + cx.send_request(GitIsRepositoryMessage(GitRepositoryPathRequest { + repository_path: repository_path.to_string(), + })) + }) + .await?; + Ok(value) + } + + /// Get the working-tree status of a Git repository via `git/getStatus`. + pub async fn git_get_status( + &self, + repository_path: &str, + ) -> Result { + let GitGetStatusResponse(status) = self + .rpc(|cx| { + cx.send_request(GitGetStatusMessage(GitRepositoryPathRequest { + repository_path: repository_path.to_string(), + })) + }) + .await?; + Ok(status) + } + + /// List the branches of a Git repository via `git/getBranches`. + pub async fn git_get_branches( + &self, + repository_path: &str, + include_remote: bool, + ) -> Result> { + let GitGetBranchesResponse { branches } = self + .rpc(|cx| { + cx.send_request(GitGetBranchesMessage(GitBranchesRequest { + repository_path: repository_path.to_string(), + include_remote: Some(include_remote), + })) + }) + .await?; + Ok(branches) + } + + /// List all agent profile configs via `config/getAgentProfileConfigs`. + pub async fn get_agent_profile_configs( + &self, + ) -> Result> + { + let GetAgentProfileConfigsResponse { profiles } = self + .rpc(|cx| cx.send_request(GetAgentProfileConfigsMessage {})) + .await?; + Ok(profiles) + } + + /// Get a single agent profile config via `config/getAgentProfileConfig`. + pub async fn get_agent_profile_config( + &self, + agent_id: &str, + ) -> Result { + let GetAgentProfileConfigResponse(view) = self + .rpc(|cx| { + cx.send_request(GetAgentProfileConfigMessage { + agent_id: agent_id.to_string(), + }) + }) + .await?; + Ok(view) + } + + /// List all AI model configs via `config/getModelConfigs`. + pub async fn get_model_configs( + &self, + ) -> Result> { + let GetModelConfigsResponse { models } = self + .rpc(|cx| cx.send_request(GetModelConfigsMessage {})) + .await?; + Ok(models) + } + + /// Read a single config path via `config/getConfig`. Returns the raw JSON + /// value at that path (`None` path reads the whole config tree). A missing + /// path surfaces as an error whose `message` matches the desktop contract + /// (`Failed to get config: Not found: Config path '' not found`) so + /// the frontend `ConfigAPI.getConfig` can swallow it into `undefined`. + pub async fn get_config(&self, path: Option<&str>) -> Result { + let GetConfigResponse(value) = self + .rpc(|cx| { + cx.send_request(GetConfigMessage { + path: path.map(str::to_string), + skip_retry_on_not_found: false, + }) + }) + .await?; + Ok(value) + } + + /// Read multiple config paths in one batch via `config/getConfigs`. + /// Returns a path -> value map; paths are deduped server-side the way the + /// desktop host does. The first not-found path aborts the batch and + /// surfaces the same not-found message shape as [`get_config`]. + pub async fn get_configs( + &self, + paths: &[String], + ) -> Result> { + let GetConfigsResponse { configs } = self + .rpc(|cx| { + cx.send_request(GetConfigsMessage { + paths: paths.to_vec(), + skip_retry_on_not_found: false, + }) + }) + .await?; + Ok(configs) + } + + /// Write a value at a config path via `config/setConfig` (Track B). The + /// handler reaches the global config singleton the Desktop host uses. + pub async fn set_config(&self, path: &str, value: serde_json::Value) -> Result<()> { + let SetConfigResponse {} = self + .rpc(|cx| { + cx.send_request(SetConfigMessage { + path: path.to_string(), + value, + }) + }) + .await?; + Ok(()) + } + + /// Read the current runtime locale id via `i18n/getCurrentLanguage`. + pub async fn i18n_get_current_language(&self) -> Result { + let I18nGetCurrentLanguageResponse { language } = self + .rpc(|cx| cx.send_request(I18nGetCurrentLanguageMessage {})) + .await?; + Ok(language) + } + + /// Set the runtime locale via `i18n/setLanguage` and sync the global + /// I18nService. Returns the validated locale id that was applied. + pub async fn i18n_set_language(&self, language: &str) -> Result { + let I18nSetLanguageResponse { language } = self + .rpc(|cx| { + cx.send_request(I18nSetLanguageMessage { + language: language.to_string(), + }) + }) + .await?; + Ok(language) + } + + /// Read the i18n config (current/fallback language, autoDetect) via + /// `i18n/getConfig`. + pub async fn i18n_get_config( + &self, + ) -> Result { + let response = self + .rpc(|cx| cx.send_request(I18nGetConfigMessage {})) + .await?; + Ok(response) + } + + /// Write the i18n config (writes `currentLanguage` and syncs the I18nService) + /// via `i18n/setConfig`. + pub async fn i18n_set_config( + &self, + current_language: Option<&str>, + fallback_language: Option<&str>, + auto_detect: bool, + ) -> Result<()> { + let I18nSetConfigResponse {} = self + .rpc(|cx| { + cx.send_request(I18nSetConfigMessage { + current_language: current_language.map(str::to_string), + fallback_language: fallback_language.map(str::to_string), + auto_detect, + }) + }) + .await?; + Ok(()) + } + + /// List all supported locales via `i18n/getSupportedLanguages`. + pub async fn i18n_get_supported_languages( + &self, + ) -> Result> { + let I18nGetSupportedLanguagesResponse { locales } = self + .rpc(|cx| cx.send_request(I18nGetSupportedLanguagesMessage {})) + .await?; + Ok(locales) + } + + /// Send a JSON-RPC request through the app-server connection and await its + /// response. Uses the canonical `on_receiving_result` + oneshot pattern + /// (mirrors the app-server round-trip tests) so the calling task is not + /// blocked on the connection driver. + async fn rpc(&self, send: F) -> Result + where + F: FnOnce(&ConnectionTo) -> SentRequest, + R: JsonRpcResponse + Send, + { + 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())? + } +} + +/// Connect an app-server client over a host-supplied transport. +/// +/// Drives an [`AppClient`] on `transport`, parks the [`ConnectionTo`] +/// for the lifetime of the returned handle, and fans projected `agent/event` +/// notifications out through the returned [`AppServerClient`]. The host owns the +/// transport and the server half of the connection (for example it spawns +/// [`crate::BitfunAppServer::serve`] on the server half of an +/// [`crate::transport::in_memory_channel_pair`] before calling this). +/// +/// The connection is parked for the lifetime of the host: the shutdown signal +/// is intentionally never sent, so the spawned connection task (which owns the +/// transport) stays alive while the [`AppServerClient`] is in use. +/// +/// # Errors +/// +/// Returns an error if the connection closes or the startup handshake does not +/// complete within the startup grace window. +pub async fn connect( + transport: impl ConnectTo + 'static, +) -> std::result::Result { + // Event fan-out: the client receives `agent/event` notifications forwarded + // by the app-server, projects each envelope to the frontend shape, and + // broadcasts it to consumers. + let (event_tx, _) = broadcast::channel::(1024); + let event_tx_for_task = 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. + 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-app-client") + .on_receive_notification( + { + let event_tx = event_tx_for_task.clone(); + async move |notification: SessionEventNotification, + _cx: ConnectionTo| { + let SessionEventNotification(envelope) = notification; + if let Some(projected) = project_agentic_frontend_event(envelope.event) { + let _ = event_tx.send(FrontendEvent { + event: projected.event_name, + payload: projected.payload, + }); + } + 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; + // 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. + if let Ok(payload) = serde_json::to_value(&event) { + let _ = event_tx.send(FrontendEvent { + event: "permission://event".to_string(), + payload, + }); + } + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .connect_with(transport, async |cx: ConnectionTo| { + let _ = cx_tx.send(cx); + let _ = shutdown_rx.await; + Ok(()) + }) + .await; + if let Err(error) = &result { + log::warn!("App-server client connection ended with error: {:?}", error); + } + }); + + 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 client connection closed before startup completed"); + } + Err(_) => { + connect_task.abort(); + anyhow::bail!("App-server client connection startup timed out"); + } + }; + + // The connection is parked for the lifetime of the host: the `shutdown_tx` + // lives in the returned handle so the parked `connect_task` main loop only + // resumes when the host calls `shutdown` (or drops the last clone). The + // Server Host holds the handle for the process lifetime and never calls + // `shutdown`, so the connection -- and therefore the in-process app-server + // `BitfunAppServer` it is paired with -- stays alive as long as the server + // does. (Previously this was `let _ = shutdown_tx;`, which dropped the + // sender immediately and let the parked main loop resume right after + // `connect` returned, killing the connection -- every subsequent RPC then + // surfaced as `send failed because receiver is gone`.) + Ok(AppServerClient { + connection: Arc::new(connection), + event_tx, + shutdown_tx: Arc::new(std::sync::Mutex::new(Some(shutdown_tx))), + }) +} diff --git a/src/crates/interfaces/app-server/src/lib.rs b/src/crates/interfaces/app-server/src/lib.rs new file mode 100644 index 000000000..865f092df --- /dev/null +++ b/src/crates/interfaces/app-server/src/lib.rs @@ -0,0 +1,90 @@ +//! BitFun generic JSON-RPC app-server surface. +//! +//! This crate owns a protocol-agnostic JSON-RPC server/client scaffold built on +//! [`agent_client_protocol`] using custom roles ([`AppServer`]/[`AppClient`]), +//! instead of the built-in ACP `Agent`/`Client` roles. Consumers register their +//! own `JsonRpcRequest` / `JsonRpcNotification` types; the crate binds no +//! schema method set, unlike [`bitfun_acp`]. +//! +//! The optional `agent` module is the Phase 2 wiring: [`BitfunAppServer`] +//! exposes a ready set of agent kernel operations (create / list / delete / +//! submit / run / cancel) over a host-injected [`AgentRuntime`], using the +//! generic `AppServer` role and the schema in [`schema`]. Hosts that want a +//! purely schema-free scaffold can ignore `agent` / `schema` / `server` and +//! register their own message types directly on `AppServer::builder()`. +//! +//! # Example +//! +//! ```no_run +//! use bitfun_app_server::{AppServer, AppClient, transport}; +//! use bitfun_app_server::prelude::*; +//! # use serde::{Deserialize, Serialize}; +//! # +//! # #[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcRequest)] +//! # #[request(method = "ping", response = Pong)] +//! # struct Ping; +//! # #[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcResponse)] +//! # struct Pong; +//! # async fn run() -> Result<(), agent_client_protocol::Error> { +//! let (server_transport, client_transport) = transport::in_memory_channel_pair(); +//! // server: register handlers and connect_to +//! // client: connect_with and send_request +//! # Ok(()) +//! # } +//! ``` +//! +//! [`bitfun_acp`]: bitfun_acp +//! +//! # Crate boundary (preview) +//! +//! This crate is an **internal interface crate**, not a versioned public API. +//! The server-side surface ([`BitfunAppServer`], [`schema`], [`agent`]) is the +//! production path consumed by the Server Host. The client-side exports +//! ([`AppServerClient`], [`FrontendEvent`], [`connect`]) are test-only +//! utilities -- they have no production consumer and are `#[doc(hidden)]` to +//! avoid implying a stable public SDK. They will be replaced by a proper +//! versioned event envelope and connection protocol in a follow-up. + +// Lifted from the default 128: the `AppServer` builder chains one +// `ChainedHandler` layer per registered request handler, and with the +// agent-kernel + permission + git + config surface all on one builder the +// monomorphized handler tower overflows the default recursion limit when the +// `agent_kernel` integration test instantiates the full `BitfunAppServer::serve` +// connection. Raise it so the chain keeps compiling as more host-service groups +// land under option C. +#![recursion_limit = "256"] + +pub mod agent; +pub mod client; +pub mod role; +pub mod schema; +pub mod server; +pub mod transport; + +pub use agent::BitfunAppRuntime; +pub use agent_client_protocol as protocol; +// `connect`, `AppServerClient`, and `FrontendEvent` are test-only utilities +// with no production consumer. They are `#[doc(hidden)]` to avoid implying a +// versioned public client SDK; they will be replaced by a proper versioned +// event envelope in a follow-up. +#[doc(hidden)] +pub use client::{connect, AppServerClient, FrontendEvent}; +pub use role::{AppClient, AppServer}; +pub use server::BitfunAppServer; + +/// Convenience prelude for consumers building an app-server connection. +pub mod prelude { + pub use crate::{ + agent, client, schema, server, transport, AppClient, AppServer, + BitfunAppRuntime, BitfunAppServer, + }; + pub use agent_client_protocol::{ + Builder, ConnectionTo, Dispatch, Handled, JsonRpcNotification, JsonRpcRequest, + JsonRpcResponse, Responder, SentRequest, + }; + // Macro re-exports so callers do not need a direct `agent_client_protocol` + // path for handler registration. + pub use agent_client_protocol::{ + on_receive_dispatch, on_receive_notification, on_receive_request, + }; +} diff --git a/src/crates/interfaces/app-server/src/role.rs b/src/crates/interfaces/app-server/src/role.rs new file mode 100644 index 000000000..acf80cf6f --- /dev/null +++ b/src/crates/interfaces/app-server/src/role.rs @@ -0,0 +1,106 @@ +//! 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`). + +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 + } +} diff --git a/src/crates/interfaces/app-server/src/schema.rs b/src/crates/interfaces/app-server/src/schema.rs new file mode 100644 index 000000000..55ef62a89 --- /dev/null +++ b/src/crates/interfaces/app-server/src/schema.rs @@ -0,0 +1,763 @@ +//! 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::{ + 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, + 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, + 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/server.rs b/src/crates/interfaces/app-server/src/server.rs new file mode 100644 index 000000000..9bf9dde94 --- /dev/null +++ b/src/crates/interfaces/app-server/src/server.rs @@ -0,0 +1,733 @@ +//! BitFun agent kernel server backed by 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`. + +use std::sync::Arc; + +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; + +/// 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 crate::agent::{ + bitfun_error, config_get_error, git_service_error, runtime_call, 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, +}; + +/// 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, +} + +impl BitfunAppServer { + pub fn new(runtime: BitfunAppRuntime) -> Self { + Self { + runtime: Arc::new(runtime), + } + } + + /// Shared runtime handle, for callers that want to spawn side tasks such + /// as an event-forwarding loop on the same runtime. + 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`). + pub async fn serve(self, transport: impl ConnectTo + 'static) -> Result<()> { + let runtime = self.runtime; + + 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). + .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(()) + }) + .await + } +} diff --git a/src/crates/interfaces/app-server/src/transport.rs b/src/crates/interfaces/app-server/src/transport.rs new file mode 100644 index 000000000..ba0827440 --- /dev/null +++ b/src/crates/interfaces/app-server/src/transport.rs @@ -0,0 +1,21 @@ +//! Transport helpers for wiring an in-process app-server connection. + +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() +} diff --git a/src/crates/interfaces/app-server/tests/agent_kernel.rs b/src/crates/interfaces/app-server/tests/agent_kernel.rs new file mode 100644 index 000000000..3859283a4 --- /dev/null +++ b/src/crates/interfaces/app-server/tests/agent_kernel.rs @@ -0,0 +1,604 @@ +#![recursion_limit = "512"] + +//! Phase 2 integration tests: the generic app-server role exposes real +//! `bitfun_agent_runtime` SDK operations over the in-memory channel transport. +//! +//! The mock provider only implements `AgentSubmissionPort` (the port behind +//! `run`, `create_session`, and `submit_turn`), matching `sdk_minimal.rs`. The +//! other operations (`list_sessions`, `delete_session`, `cancel_turn`) need +//! separate ports; without them injected the runtime returns a missing-port +//! error, which these tests assert maps to an error at the JSON-RPC boundary. +//! +//! Each `on_receive_request` on `BitfunAppServer::serve` chains a +//! `ChainedHandler` layer; the full agent-kernel + permission + git + config +//! surface now monomorphizes into a handler tower deeper than the default +//! recursion limit when this test instantiates the connection. The lifted +//! limit keeps the chain compiling as more host-service groups land. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use agent_client_protocol::{ConnectionTo, 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, +}; +use bitfun_app_server::schema::{ + CancelTurnMessage, CreateSessionMessage, CreateSessionResponse, DeleteSessionMessage, + FrontendEventNotification, ListSessionsMessage, RespondPermissionMessage, RunMessage, + RunResponse, RunSessionSpec, SubmitDialogTurnBody, SubmitDialogTurnMessage, + SubmitDialogTurnResponse, SubmitTurnMessage, SubmitTurnResponse, +}; +use bitfun_app_server::{transport, AppClient, AppServer, BitfunAppRuntime, BitfunAppServer}; +use tokio::task::LocalSet; + +/// Minimal `AgentSubmissionPort` mock modeled on `sdk_minimal.rs`. +#[derive(Debug, Default)] +struct ExampleAgentProvider { + created_sessions: Mutex>, + submitted_turns: Mutex>, + submitted_dialog_turns: Mutex>, +} + +#[async_trait] +impl AgentSubmissionPort for ExampleAgentProvider { + async fn create_session( + &self, + request: AgentSessionCreateRequest, + ) -> PortResult { + self.created_sessions.lock().unwrap().push(request.clone()); + Ok(AgentSessionCreateResult { + session_id: "example-session".to_string(), + session_name: request.session_name, + agent_type: request.agent_type, + }) + } + + async fn submit_message( + &self, + request: AgentSubmissionRequest, + ) -> PortResult { + self.submitted_turns.lock().unwrap().push(request.clone()); + Ok(AgentSubmissionResult { + turn_id: request + .turn_id + .unwrap_or_else(|| "example-turn".to_string()), + accepted: true, + }) + } + + async fn resolve_session_agent_type(&self, _session_id: &str) -> PortResult> { + Ok(Some("agentic".to_string())) + } +} + +#[async_trait::async_trait] +impl bitfun_agent_runtime::sdk::AgentDialogTurnPort for ExampleAgentProvider { + async fn submit_dialog_turn( + &self, + request: bitfun_agent_runtime::sdk::AgentDialogTurnRequest, + ) -> PortResult { + self.submitted_dialog_turns + .lock() + .unwrap() + .push(request.clone()); + Ok(bitfun_agent_runtime::sdk::DialogSubmitOutcome::Started { + session_id: request.session_id, + turn_id: request + .turn_id + .unwrap_or_else(|| "example-dialog-turn".to_string()), + }) + } +} + +fn build_runtime() -> bitfun_agent_runtime::sdk::AgentRuntime { + let provider = Arc::new(ExampleAgentProvider::default()); + let events = AgentEventStream::new(); + AgentRuntimeBuilder::new() + .with_submission_port(provider.clone()) + .with_dialog_turn_port(provider) + .with_event_stream(events) + .build() + .expect("runtime should build with submission + dialog turn ports") +} + +/// Wrap the test runtime with a fresh `AgentEventSource` backed by an isolated +/// `EventQueue`, so the app-server's event forwarder has something to drain. +fn build_app_runtime() -> BitfunAppRuntime { + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let event_source = AgentEventSource::new(event_queue); + BitfunAppRuntime::new(build_runtime(), event_source) +} + +/// Like [`build_app_runtime`] but also hands back the backing `EventQueue` so a +/// test can publish into it and assert the server forwards the event to the +/// client as an `agent/event` notification. +fn build_app_runtime_with_queue() -> (BitfunAppRuntime, Arc) { + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let event_source = AgentEventSource::new(event_queue.clone()); + ( + BitfunAppRuntime::new(build_runtime(), event_source), + event_queue, + ) +} + +async fn recv(response: SentRequest) -> Result +where + T: agent_client_protocol::JsonRpcResponse + Send, +{ + let (tx, rx) = tokio::sync::oneshot::channel(); + response.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())? +} + +fn spawn_server( + runtime: BitfunAppRuntime, + transport: impl agent_client_protocol::ConnectTo + 'static, +) { + tokio::task::spawn_local(async move { + let _ = BitfunAppServer::new(runtime).serve(transport).await; + }); +} + +#[tokio::test(flavor = "current_thread")] +async fn run_round_trips_through_create_and_submit() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let runtime = build_app_runtime(); + spawn_server(runtime, server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let response = recv(cx.send_request(RunMessage { + session: RunSessionSpec::Create { + session_name: "Example SDK Session".to_string(), + agent_type: "agentic".to_string(), + workspace_path: None, + }, + message: "hello from an app-server client".to_string(), + turn_id: None, + source: Some(AgentSubmissionSource::Cli), + })) + .await?; + assert_eq!(response.session_id, "example-session"); + assert_eq!(response.turn_id, "example-turn"); + assert_eq!(response.agent_type.as_deref(), Some("agentic")); + assert!(response.accepted); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn submit_dialog_turn_carries_agent_type_and_starts() { + // `start_dialog_turn`-style calls must route to `agent/submitDialogTurn` + // (not `agent/submitTurn`): the dialog-turn body carries `agentType` and a + // `policy`, which the bare submission request does not. This test pins the + // field path: the mock records the dialog request, the server defaults the + // omitted `policy` to the desktop UI source, and the response is `Started`. + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let runtime = build_app_runtime(); + spawn_server(runtime, server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let response = recv(cx.send_request(SubmitDialogTurnMessage( + SubmitDialogTurnBody { + session_id: "example-session".to_string(), + message: "hello dialog".to_string(), + original_message: None, + turn_id: None, + agent_type: "agentic".to_string(), + workspace_path: None, + remote_connection_id: None, + remote_ssh_host: None, + policy: None, + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }, + ))) + .await?; + let SubmitDialogTurnResponse::Started { + session_id, + turn_id, + } = response + else { + panic!("expected Started, got {response:?}"); + }; + assert_eq!(session_id, "example-session"); + assert_eq!(turn_id, "example-dialog-turn"); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn respond_permission_routes_to_the_permission_surface() { + // The permission commands must route through the app-server `agent/*` + // surface. The mock runtime here ships without a permission request + // manager (matching `cancel_turn`'s missing-port test), so the SDK + // returns `MissingPermissionRequestManager`; what this pins is that the + // request reaches the handler and the runtime error surfaces cleanly as a + // JSON-RPC error -- not an "unknown method" fallthrough. + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let runtime = build_app_runtime(); + spawn_server(runtime, server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let result = recv(cx.send_request(RespondPermissionMessage { + request_id: "perm-1".to_string(), + reply: bitfun_agent_runtime::sdk::PermissionReply::Once, + })) + .await; + assert!( + result.is_err(), + "respondPermission without a permission manager should error, got {result:?}" + ); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn create_session_returns_provider_session_id() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let runtime = build_app_runtime(); + spawn_server(runtime, server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let response = recv(cx.send_request(CreateSessionMessage( + AgentSessionCreateRequest { + session_name: "direct create".to_string(), + agent_type: "agentic".to_string(), + workspace_path: None, + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + model_id: None, + metadata: Default::default(), + }, + ))) + .await?; + let CreateSessionResponse(inner) = response; + assert_eq!(inner.session_id, "example-session"); + assert_eq!(inner.agent_type, "agentic"); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn submit_turn_surfaces_provider_result() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let runtime = build_app_runtime(); + spawn_server(runtime, server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let response = + recv(cx.send_request(SubmitTurnMessage(AgentSubmissionRequest { + session_id: "example-session".to_string(), + message: "follow-up message".to_string(), + turn_id: None, + source: Some(AgentSubmissionSource::Cli), + attachments: Vec::new(), + metadata: Default::default(), + }))) + .await?; + let SubmitTurnResponse(inner) = response; + assert_eq!(inner.turn_id, "example-turn"); + assert!(inner.accepted); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn list_sessions_maps_missing_port_to_internal_error() { + // `AgentSessionManagementPort` is not injected, so the runtime returns + // `MissingSessionManagementPort`. The server must surface that as a + // JSON-RPC error, not crash. + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let runtime = build_app_runtime(); + spawn_server(runtime, server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let result = recv(cx.send_request(ListSessionsMessage( + AgentSessionListRequest { + workspace_path: ".".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }, + ))) + .await; + assert!( + result.is_err(), + "listSessions without session-management port should error, got {result:?}" + ); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn delete_session_maps_missing_port_to_internal_error() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let runtime = build_app_runtime(); + spawn_server(runtime, server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let result = recv(cx.send_request(DeleteSessionMessage( + AgentSessionDeleteRequest { + workspace_path: ".".to_string(), + session_id: "example-session".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }, + ))) + .await; + assert!( + result.is_err(), + "deleteSession without session-management port should error, got {result:?}" + ); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn cancel_turn_maps_missing_port_to_internal_error() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let runtime = build_app_runtime(); + spawn_server(runtime, server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let result = recv(cx.send_request(CancelTurnMessage( + AgentTurnCancellationRequest { + session_id: "example-session".to_string(), + turn_id: None, + source: Some(AgentSubmissionSource::Cli), + requester_session_id: None, + reason: None, + wait_timeout_ms: None, + }, + ))) + .await; + assert!( + result.is_err(), + "cancelTurn without cancellation port should error, got {result:?}" + ); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn unknown_agent_method_returns_method_not_found() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let runtime = build_app_runtime(); + spawn_server(runtime, server_transport); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let response = recv(cx.send_request(UnknownAgentRequest)).await; + assert!( + response.is_err(), + "unknown method should yield method_not_found, got {response:?}" + ); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +/// The app-server must forward runtime events from its injected +/// `AgentEventSource` to the client as `agent/event` notifications, not leave +/// the client to subscribe to the runtime queue directly. +#[tokio::test(flavor = "current_thread")] +async fn runtime_events_are_forwarded_as_agent_event_notifications() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let (runtime, event_queue) = build_app_runtime_with_queue(); + spawn_server(runtime, server_transport); + + let received: Arc>> = + Arc::new(Mutex::new(Vec::new())); + let received_for_client = received.clone(); + let queue_for_client = event_queue.clone(); + + let result = AppClient + .builder() + .on_receive_notification( + { + let received = received_for_client.clone(); + async move |notification: FrontendEventNotification, + _cx: ConnectionTo| { + received.lock().unwrap().push(notification); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .connect_with(client_transport, async |_cx: ConnectionTo| { + // Let the server's forwarder subscribe before publishing. + tokio::time::sleep(Duration::from_millis(50)).await; + queue_for_client + .enqueue( + AgenticEvent::SessionStateChanged { + session_id: "s1".to_string(), + new_state: "ready".to_string(), + }, + None, + ) + .await + .expect("event should enqueue"); + // Allow time for the server forwarder + client dispatch. + tokio::time::sleep(Duration::from_millis(200)).await; + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + + let received = received.lock().unwrap().clone(); + 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 + ); + }) + .await; +} + +#[derive( + Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcRequest, +)] +#[request(method = "agent/__unknown_for_test", response = UnknownAgentResponse)] +struct UnknownAgentRequest; + +#[derive( + Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcResponse, +)] +struct UnknownAgentResponse; + +#[allow(dead_code)] +fn _document_run_response_shape_in_tests(_r: RunResponse) {} + +/// The Server Host drives the app-server through the real `client::connect` +/// handle (not an inline `AppClient::builder().connect_with` main_fn like the +/// round-trip tests above). `connect` parks its main loop on a shutdown +/// receiver and returns an `AppServerClient` the host holds for the process +/// lifetime. This regression test pins that contract: after `connect` +/// returns, an RPC sent through the returned handle must still reach the +/// server and get a response. A previous version dropped `shutdown_tx` right +/// before returning (`let _ = shutdown_tx;`), which let the parked main loop +/// resume immediately, cancelling the connection's background actors -- every +/// subsequent RPC then surfaced as `send failed because receiver is gone` +/// (from `Task::spawn`'s `unbounded_send` failure). This test fails loud if +/// that regression returns, because `create_session` would error instead of +/// returning the mock session id. +#[tokio::test(flavor = "current_thread")] +async fn client_connect_keeps_connection_alive_after_return() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let runtime = build_app_runtime(); + spawn_server(runtime, server_transport); + + let client = bitfun_app_server::connect(client_transport) + .await + .expect("app-server client should connect"); + + // The connect task must still be parked on the shutdown receiver + // here -- otherwise the connection's background actors are gone and + // this RPC surfaces `send failed because receiver is gone`. + let response = client + .create_session(AgentSessionCreateRequest { + session_name: "post-connect session".to_string(), + agent_type: "agentic".to_string(), + workspace_path: None, + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + model_id: None, + metadata: Default::default(), + }) + .await + .expect("RPC after connect() must succeed -- connection should still be alive"); + assert_eq!(response.session_id, "example-session"); + assert_eq!(response.agent_type, "agentic"); + + client.shutdown().await; + }) + .await; +} diff --git a/src/crates/interfaces/app-server/tests/round_trip.rs b/src/crates/interfaces/app-server/tests/round_trip.rs new file mode 100644 index 000000000..874958bf6 --- /dev/null +++ b/src/crates/interfaces/app-server/tests/round_trip.rs @@ -0,0 +1,168 @@ +//! Integration tests for the generic app-server scaffold: request/response +//! round-trip, notification delivery, and method_not_found dispatch fallback, +//! all over the in-memory duplex transport. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use agent_client_protocol::{ConnectionTo, Dispatch, JsonRpcResponse, Responder, SentRequest}; +use bitfun_app_server::{transport, AppClient, AppServer}; +use serde::{Deserialize, Serialize}; +use tokio::task::LocalSet; + +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcRequest)] +#[request(method = "app/greet", response = GreetResponse)] +struct GreetRequest { + name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcResponse)] +struct GreetResponse { + message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcNotification)] +#[notification(method = "app/log")] +struct LogNotification { + line: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcRequest)] +#[request(method = "app/unknown", response = UnknownResponse)] +struct UnknownRequest; + +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcResponse)] +struct UnknownResponse; + +async fn recv( + response: SentRequest, +) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + response.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())? +} + +#[tokio::test(flavor = "current_thread")] +async fn request_response_round_trip() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + + let server = AppServer.builder().name("test-server").on_receive_request( + async |request: GreetRequest, + responder: Responder, + _cx: ConnectionTo| { + responder.respond(GreetResponse { + message: format!("hello, {}!", request.name), + }) + }, + agent_client_protocol::on_receive_request!(), + ); + + tokio::task::spawn_local(async move { + let _ = server.connect_to(server_transport).await; + }); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let response = recv(cx.send_request(GreetRequest { + name: "bitfun".to_string(), + })) + .await?; + assert_eq!(response.message, "hello, bitfun!"); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn notification_delivery() { + let logs = Arc::new(Mutex::new(Vec::::new())); + + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + let logs_for_server = logs.clone(); + + let server = AppServer.builder().on_receive_notification( + { + let logs = logs_for_server.clone(); + async move |notification: LogNotification, _cx: ConnectionTo| { + logs.lock().unwrap().push(notification.line); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ); + + tokio::task::spawn_local(async move { + let _ = server.connect_to(server_transport).await; + }); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + cx.send_notification(LogNotification { + line: "line one".to_string(), + })?; + cx.send_notification(LogNotification { + line: "line two".to_string(), + })?; + tokio::time::sleep(Duration::from_millis(100)).await; + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; + + let received = logs.lock().unwrap().clone(); + assert_eq!( + received, + vec!["line one".to_string(), "line two".to_string()] + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn unknown_method_returns_method_not_found() { + let local = LocalSet::new(); + local + .run_until(async { + let (server_transport, client_transport) = transport::in_memory_channel_pair(); + + let server = AppServer.builder().on_receive_dispatch( + async |message: Dispatch, cx: ConnectionTo| { + message.respond_with_error(agent_client_protocol::Error::method_not_found(), cx) + }, + agent_client_protocol::on_receive_dispatch!(), + ); + + tokio::task::spawn_local(async move { + let _ = server.connect_to(server_transport).await; + }); + + let result = AppClient + .builder() + .connect_with(client_transport, async |cx: ConnectionTo| { + let result = recv(cx.send_request(UnknownRequest)).await; + assert!( + result.is_err(), + "unknown method should yield an error, got {result:?}" + ); + Ok(()) + }) + .await; + assert!(result.is_ok(), "{result:?}"); + }) + .await; +} diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index e117305c9..bb06088a5 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -14,6 +14,7 @@ tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +ts-rs = { workspace = true, optional = true } bitfun-agent-runtime = { path = "../../execution/agent-runtime", optional = true } bitfun-agent-tools = { path = "../../execution/tool-contracts", optional = true } bitfun-events = { path = "../../contracts/events" } @@ -84,6 +85,10 @@ windows = { workspace = true, optional = true, features = [ [features] default = [] +# Derive `ts_rs::TS` on wire types (git/* today). Types that live behind a +# feature group (e.g. `git`) only derive `TS` when that group is also enabled; +# app-server's `ts` feature enables `product-full` which pulls `git`. +ts = ["dep:ts-rs"] announcement = ["reqwest"] browser-control = ["anyhow", "bitfun-services-core", "dirs", "reqwest", "thiserror"] canvas-runtime = [ diff --git a/src/crates/services/services-integrations/src/git/types.rs b/src/crates/services/services-integrations/src/git/types.rs index 3a485c824..edd384d0c 100644 --- a/src/crates/services/services-integrations/src/git/types.rs +++ b/src/crates/services/services-integrations/src/git/types.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct GitRepository { pub path: String, pub name: String, @@ -15,6 +16,7 @@ pub struct GitRepository { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct GitStatus { pub staged: Vec, pub unstaged: Vec, @@ -27,6 +29,7 @@ pub struct GitStatus { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct GitFileStatus { pub path: String, pub status: String, @@ -35,6 +38,7 @@ pub struct GitFileStatus { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct GitBranch { pub name: String, pub current: bool, @@ -65,6 +69,7 @@ pub struct GitBranch { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct GitBranchStats { pub commit_count: i32, pub contributor_count: i32, @@ -74,6 +79,7 @@ pub struct GitBranchStats { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct GitLinesChanged { pub additions: i32, pub deletions: i32, diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 6db41d195..e5e381aef 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -7,9 +7,11 @@ "scripts": { "dev": "vite", "dev:force": "vite --force", + "gen:types": "cargo test --package bitfun-app-server --features ts --no-default-features export -- --nocapture && node scripts/gen-api-barrel.mjs", "build": "vite build", "build:desktop": "vite build --mode desktop", "build:web": "vite build --mode web", + "build:with-types": "npm run gen:types && npm run build", "preview": "vite preview", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/src/web-ui/scripts/gen-api-barrel.mjs b/src/web-ui/scripts/gen-api-barrel.mjs new file mode 100644 index 000000000..1cf5f06a4 --- /dev/null +++ b/src/web-ui/scripts/gen-api-barrel.mjs @@ -0,0 +1,28 @@ +// Generates src/generated/api/index.ts — a barrel re-exporting every +// ts-rs-generated type. Run after `cargo test ... export` (gen:types). +// Generated files are gitignored; this barrel is regenerated alongside them. +import { readdir, writeFile } from 'node:fs/promises'; +import { join, basename, extname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = fileURLToPath(new URL('.', import.meta.url)); +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 +// 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'\`. +// +// Regenerated by \`npm run gen:types\` — do not edit by hand. +`; + +const files = (await readdir(dir, { withFileTypes: true })) + .filter((e) => e.isFile() && e.name.endsWith('.ts') && e.name !== 'index.ts') + .map((e) => basename(e.name, extname(e.name))) + .sort(); + +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 new file mode 100644 index 000000000..392d4c039 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from 'vitest'; +import { + AGENT_COMMAND_SCHEMA, + decodeResponseBody, + encodeRequestBody, + resolveWsMethod, +} from './websocket-adapter'; +import type { + SubmitDialogTurnBody, + SubmitDialogTurnResponse, +} from '@/generated/api'; + +describe('resolveWsMethod', () => { + it('maps every app-server agent command to its agent/* surface method', () => { + // The snake_case Tauri command names the service-API call sites use are + // resolved to the schema `group/verb` method names via the typed + // `AGENT_COMMAND_SCHEMA` table (web transport only -- desktop/CLI call Tauri + // commands directly). If one is added/removed on the Rust side, update the + // table and this case in lockstep. + expect(resolveWsMethod('create_session')).toBe('agent/createSession'); + expect(resolveWsMethod('list_sessions')).toBe('agent/listSessions'); + expect(resolveWsMethod('delete_session')).toBe('agent/deleteSession'); + // `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 + // `policy` -- see `agentic_api.rs::start_dialog_turn` for the desktop path. + expect(resolveWsMethod('start_dialog_turn')).toBe('agent/submitDialogTurn'); + expect(resolveWsMethod('cancel_dialog_turn')).toBe('agent/cancelTurn'); + // Permission surface: reply/list/grants map to the app-server permission + // methods. `subscribe_permission_requests` is satisfied by the + // `permission://event` push in web mode; mapping it to list fetches the + // current pending set once. + expect(resolveWsMethod('respond_permission')).toBe('agent/respondPermission'); + expect(resolveWsMethod('respond_permission_batch')).toBe( + 'agent/respondPermissionBatch' + ); + expect(resolveWsMethod('list_pending_permission_requests')).toBe( + 'agent/listPendingPermissionRequests' + ); + expect(resolveWsMethod('subscribe_permission_requests')).toBe( + 'agent/listPendingPermissionRequests' + ); + expect(resolveWsMethod('list_project_permission_grants')).toBe( + 'agent/listProjectPermissionGrants' + ); + expect(resolveWsMethod('remove_project_permission_grant')).toBe( + 'agent/removeProjectPermissionGrant' + ); + expect(resolveWsMethod('clear_project_permission_grants')).toBe( + 'agent/clearProjectPermissionGrants' + ); + }); + + it('passes non-agent commands through unchanged', () => { + // ping and external_sources are handled by the Server Host dispatch + // directly; commands the server does not expose over WS also flow through + // unchanged so they surface as a clean "unknown command" error rather than + // a silent rename. `get_project_permission_rules`/`save_project_permission_rules` + // are desktop-host-managed permission rule files (no AgentRuntime SDK op), + // so they pass through and fail as "unknown command" in web mode until a + // later track adds a host-services dispatch. + expect(resolveWsMethod('ping')).toBe('ping'); + expect(resolveWsMethod('get_external_source_snapshot')).toBe( + 'get_external_source_snapshot' + ); + expect(resolveWsMethod('get_project_permission_rules')).toBe( + 'get_project_permission_rules' + ); + expect(resolveWsMethod('some_future_command')).toBe('some_future_command'); + expect(resolveWsMethod('')).toBe(''); + }); + + it('maps the git service commands to their git/* surface methods', () => { + // Read-only git operations exposed by the app-server surface (option C). + // Write operations and the remote (SSH) path arrive later; the Server Host + // has no SSH manager, so remote git paths surface as + // `host_capability_unavailable` (the `external_sources` precedent). + expect(resolveWsMethod('git_is_repository')).toBe('git/isRepository'); + expect(resolveWsMethod('git_get_status')).toBe('git/getStatus'); + expect(resolveWsMethod('git_get_branches')).toBe('git/getBranches'); + }); + + it('maps the config service commands to their config/* surface methods', () => { + // Read-only config operations: agent-profile canonicalizer + AI model + // configs + single/batched config-path reads. `get_config`/`get_configs` + // carry the not-found -> undefined contract the frontend `ConfigAPI` + // depends on (the app-server surfaces the `BitFunError::NotFound` Display + // text as the JSON-RPC `message`). `get_skill_configs` still arrives + // later (workspace dependency). + expect(resolveWsMethod('get_agent_profile_configs')).toBe( + 'config/getAgentProfileConfigs' + ); + expect(resolveWsMethod('get_agent_profile_config')).toBe( + 'config/getAgentProfileConfig' + ); + 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_config')).toBe('config/setConfig'); + expect(resolveWsMethod('i18n_get_current_language')).toBe( + 'i18n/getCurrentLanguage' + ); + expect(resolveWsMethod('i18n_set_language')).toBe('i18n/setLanguage'); + expect(resolveWsMethod('i18n_get_config')).toBe('i18n/getConfig'); + expect(resolveWsMethod('i18n_set_config')).toBe('i18n/setConfig'); + expect(resolveWsMethod('i18n_get_supported_languages')).toBe( + 'i18n/getSupportedLanguages' + ); + }); + + it('pins the typed command schema (Step 1: generated types wired into the method table)', () => { + // Compile-time: the start_dialog_turn request/response slots are bound to + // the generated schema types. These locals are intentionally unused at + // runtime; they exist only so `tsc` fails if the generated barrel or the + // schema slot type drifts. + const _body: SubmitDialogTurnBody = { + sessionId: '', + message: '', + agentType: '', + }; + const _resp: SubmitDialogTurnResponse = { status: 'started', sessionId: '', turnId: '' }; + + // 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. + expect(AGENT_COMMAND_SCHEMA.start_dialog_turn.method).toBe( + 'agent/submitDialogTurn' + ); + expect(Object.keys(AGENT_COMMAND_SCHEMA).length).toBe(26); + + // 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). + expect(_body.message).toBe(''); + expect(_resp.status).toBe('started'); + }); +}); + +describe('encodeRequestBody', () => { + it('renames start_dialog_turn fields to the schema wire shape', () => { + const frontend = { + sessionId: 's1', + userInput: 'hello', + originalUserInput: 'hello!', + agentType: 'coder', + workspacePath: '/repo', + imageContexts: [{ id: 'img1', imagePath: '/tmp/a.png', mimeType: 'image/png' }], + userMessageMetadata: { hint: 'fast' }, + }; + const encoded = encodeRequestBody('start_dialog_turn', frontend); + expect(encoded.sessionId).toBe('s1'); + expect(encoded.message).toBe('hello'); + expect(encoded.originalMessage).toBe('hello!'); + expect(encoded.agentType).toBe('coder'); + expect(encoded.workspacePath).toBe('/repo'); + expect(encoded.attachments).toEqual([ + { kind: 'remote_image', id: 'img1', metadata: { imagePath: '/tmp/a.png', mimeType: 'image/png' } }, + ]); + expect(encoded.metadata).toEqual({ hint: 'fast' }); + // Original field names must not leak through. + expect(encoded.userInput).toBeUndefined(); + expect(encoded.imageContexts).toBeUndefined(); + expect(encoded.userMessageMetadata).toBeUndefined(); + }); + + it('omits optional start_dialog_turn fields when absent', () => { + const encoded = encodeRequestBody('start_dialog_turn', { + sessionId: 's1', + userInput: 'hi', + agentType: 'coder', + }); + expect(encoded.message).toBe('hi'); + expect(encoded.originalMessage).toBeUndefined(); + expect(encoded.attachments).toBeUndefined(); + expect(encoded.metadata).toBeUndefined(); + }); + + it('wraps non-object userMessageMetadata in raw_metadata', () => { + const encoded = encodeRequestBody('start_dialog_turn', { + sessionId: 's1', + userInput: 'hi', + agentType: 'coder', + userMessageMetadata: 'plain string', + }); + expect(encoded.metadata).toEqual({ raw_metadata: 'plain string' }); + }); + + it('converts respond_permission reply string to tagged-enum shape', () => { + const encoded = encodeRequestBody('respond_permission', { + requestId: 'r1', + reply: 'once', + }); + expect(encoded.request_id).toBe('r1'); + expect(encoded.reply).toEqual({ reply: 'once' }); + expect(encoded.requestId).toBeUndefined(); + }); + + it('embeds feedback into reject reply', () => { + const encoded = encodeRequestBody('respond_permission_batch', { + requestId: 'r2', + reply: 'reject', + feedback: 'no thanks', + }); + expect(encoded.request_id).toBe('r2'); + expect(encoded.reply).toEqual({ reply: 'reject', feedback: 'no thanks' }); + }); + + it('omits feedback when reply is not reject', () => { + const encoded = encodeRequestBody('respond_permission', { + requestId: 'r3', + reply: 'always', + feedback: 'should be dropped', + }); + expect(encoded.reply).toEqual({ reply: 'always' }); + }); + + 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); + }); +}); + +describe('decodeResponseBody', () => { + it('projects start_dialog_turn status to success/message', () => { + expect(decodeResponseBody('start_dialog_turn', { + status: 'started', + sessionId: 's1', + turnId: 't1', + })).toEqual({ success: true, message: 'Dialog turn started' }); + expect(decodeResponseBody('start_dialog_turn', { + status: 'queued', + sessionId: 's1', + turnId: 't1', + })).toEqual({ success: true, message: 'Dialog turn queued' }); + }); + + it('unwraps list_sessions into a bare array', () => { + expect(decodeResponseBody('list_sessions', { sessions: [{ sessionId: 's1' }] })) + .toEqual([{ sessionId: 's1' }]); + }); + + it('unwraps list_pending_permission_requests into a bare array', () => { + expect(decodeResponseBody('list_pending_permission_requests', { requests: [{ requestId: 'r1' }] })) + .toEqual([{ requestId: 'r1' }]); + }); + + it('unwraps respond_permission_batch into a bare string array', () => { + expect(decodeResponseBody('respond_permission_batch', { request_ids: ['r1', 'r2'] })) + .toEqual(['r1', 'r2']); + }); + + it('unwraps git_get_branches into a bare array', () => { + expect(decodeResponseBody('git_get_branches', { branches: [{ name: 'main' }] })) + .toEqual([{ name: 'main' }]); + }); + + it('passes unknown actions through unchanged', () => { + const result = { foo: 'bar' }; + expect(decodeResponseBody('some_unknown_action', result)).toBe(result); + expect(decodeResponseBody('get_config', result)).toBe(result); + }); +}); 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 3c991d01a..de1d46d2e 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts @@ -2,9 +2,282 @@ import { ITransportAdapter } from './base'; import { createLogger } from '@/shared/utils/logger'; +import type { + GitBranch, + GitRepositoryPathRequest, + ListSessionsResponse, + PermissionGrant, + PermissionReply, + RemoveProjectPermissionGrantResponse, + RunResponse, + SubmitDialogTurnBody, + SubmitDialogTurnResponse, +} from '@/generated/api'; 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`). + * + * The service layer (`AgentAPI` and friends) speaks Tauri command names + * (`create_session`, `start_dialog_turn`, ...) because that is the desktop + * contract. In web mode the request rides the websocket to the Server Host, + * whose dispatch (`src/apps/server/src/routes/websocket.rs`) only recognizes the + * app-server surface methods (`agent/createSession`, ...). This table is the one + * place that bridges the two naming conventions, so the typed service API stays + * transport-agnostic and desktop/web share identical call sites. + * + * `AGENT_COMMAND_TO_WS_METHOD` (below) is derived from this table so the + * exhaustive-and-stable test stays green and the snake->method mapping remains a + * single source. The `request`/`response` slots are type-only (no runtime + * value); they back the `request()` overload so callers that use a known + * command key get schema-typed bodies and responses. + * + * NOTE(Step 1): the typed `request`/`response` slots reflect the **schema wire + * shape**. For `start_dialog_turn` the frontend currently sends the desktop host + * shape (`userInput`/`dialogTurnId`/`imageContexts`) and `websocket.rs` + * normalizes it to the schema shape (`message`/`turnId`/`attachments`) -- that + * normalizer is removed in Step 2 once call sites migrate to the schema shape. + * Until then the slot is annotated with the schema type so the drift is + * type-visible. + */ +interface AgentCommandEntry { + method: string; + request?: unknown; + response?: unknown; +} + +export const AGENT_COMMAND_SCHEMA = { + create_session: { method: 'agent/createSession' }, + list_sessions: { + method: 'agent/listSessions', + response: null as unknown as ListSessionsResponse, + }, + delete_session: { method: 'agent/deleteSession' }, + // `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 + // drives `AgentRuntime::submit_dialog_turn` from the `start_dialog_turn` + // Tauri command (`agentic_api.rs`). + start_dialog_turn: { + method: 'agent/submitDialogTurn', + request: null as unknown as SubmitDialogTurnBody, + response: null as unknown as SubmitDialogTurnResponse, + }, + cancel_dialog_turn: { + method: 'agent/cancelTurn', + response: null as unknown as RunResponse, + }, + // Permission surface: the reply/list/grants operations map to the app-server + // permission methods. `subscribe_permission_requests` has no direct + // counterpart -- in web mode the app-server pushes `permission://event` + // notifications over the same connection, so subscribing is satisfied by + // fetching the current pending set once. + respond_permission: { + method: 'agent/respondPermission', + request: null as unknown as { request_id: string; reply: PermissionReply }, + }, + respond_permission_batch: { + method: 'agent/respondPermissionBatch', + request: null as unknown as { request_id: string; reply: PermissionReply }, + }, + list_pending_permission_requests: { + method: 'agent/listPendingPermissionRequests', + }, + subscribe_permission_requests: { + method: 'agent/listPendingPermissionRequests', + }, + list_project_permission_grants: { + method: 'agent/listProjectPermissionGrants', + request: null as unknown as { project_id: string }, + response: null as unknown as { grants: PermissionGrant[] }, + }, + remove_project_permission_grant: { + method: 'agent/removeProjectPermissionGrant', + request: null as unknown as PermissionGrant, + response: null as unknown as RemoveProjectPermissionGrantResponse, + }, + clear_project_permission_grants: { + method: 'agent/clearProjectPermissionGrants', + request: null as unknown as { project_id: string }, + }, + // Git service surface (read-only in this batch). The app-server schema uses + // the `group/verb` camelCase convention (`git/getStatus`) matching + // `agent/createSession`; the frontend `GitAPI` call sites speak the + // snake_case Tauri command names (`git_get_status`), so this map is the one + // place that bridges the two. Write operations 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` + // precedent). + git_is_repository: { + method: 'git/isRepository', + request: null as unknown as GitRepositoryPathRequest, + }, + git_get_status: { + method: 'git/getStatus', + request: null as unknown as GitRepositoryPathRequest, + }, + git_get_branches: { + method: 'git/getBranches', + 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 + // 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 + // contract the frontend `ConfigAPI` depends on: the app-server + // `config_get_error` helper puts the `BitFunError::NotFound` Display text + // into the JSON-RPC `message`, so `ConfigAPI.getConfig`'s substring match + // (`not found:` + `config path` + `''`) hits and swallows the error + // the same way it does on desktop. `get_skill_configs` (workspace + // dependency) still lands in a later batch. + get_agent_profile_configs: { method: 'config/getAgentProfileConfigs' }, + get_agent_profile_config: { method: 'config/getAgentProfileConfig' }, + 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_config: { method: 'config/setConfig' }, + i18n_get_current_language: { method: 'i18n/getCurrentLanguage' }, + i18n_set_language: { method: 'i18n/setLanguage' }, + i18n_get_config: { method: 'i18n/getConfig' }, + i18n_set_config: { method: 'i18n/setConfig' }, + i18n_get_supported_languages: { method: 'i18n/getSupportedLanguages' }, +} as const satisfies Record; + +export type AgentCommandKey = keyof typeof AGENT_COMMAND_SCHEMA; + +/** + * Resolve the app-server JSON-RPC method name for a frontend command. The + * snake_case Tauri command names the service-API call sites use (e.g. + * `create_session`) map to the schema `group/verb` method names via the typed + * `AGENT_COMMAND_SCHEMA` table; any command not in the table passes through + * unchanged (desktop-only commands, `ping`, ...) and surfaces as + * `method_not_found` on the browser-direct ACP path. + * + * Step 2b: the snake->method resolution is **web-transport-only** -- the desktop + * and CLI hosts still call Tauri commands by their snake_case names directly, + * so this indirection is confined to the WS adapter and does not affect them. + */ +export function resolveWsMethod(action: string): string { + const entry = (AGENT_COMMAND_SCHEMA as Record)[action]; + return entry?.method ?? action; +} + +// --------------------------------------------------------------------------- +// Protocol conversion layer (Review2-Item2) +// --------------------------------------------------------------------------- +// The frontend service-API call sites speak the desktop Tauri command shapes +// (camelCase field names, bare-string permission replies, success/message +// responses, bare-array returns). The app-server JSON-RPC schema uses a +// different wire shape per method (schema snake_case fields, tagged-enum +// PermissionReply, status/sessionId/turnId responses, wrapper structs for +// collection results). The adapter is the single place that bridges the two so +// call sites stay transport-agnostic. Each encoder/decoder is pure and +// side-effect free; an unknown action falls through unchanged. + +/** + * Encodes the frontend request body into the app-server JSON-RPC wire shape for + * the given command. Handles field-name and structural mismatches the desktop + * host does in Rust (`agentic_api.rs`). + */ +export function encodeRequestBody(action: string, body: any): any { + if (!body || typeof body !== 'object') return body ?? {}; + switch (action) { + case 'start_dialog_turn': { + const { userInput, originalUserInput, imageContexts, userMessageMetadata, ...rest } = body; + return { + ...rest, + message: userInput, + ...(originalUserInput !== undefined ? { originalMessage: originalUserInput } : {}), + ...(Array.isArray(imageContexts) && imageContexts.length > 0 + ? { attachments: imageContexts.map(encodeImageAttachment) } + : {}), + ...(userMessageMetadata !== undefined ? { metadata: encodeUserMetadata(userMessageMetadata) } : {}), + }; + } + case 'respond_permission': + case 'respond_permission_batch': + return encodePermissionResponseBody(body); + default: + return body; + } +} + +/** + * Decodes the app-server JSON-RPC result into the frontend-expected shape. + * Handles response wrapper structs (bare-array unwrapping) and the + * start_dialog_turn status-enum -> success/message projection. + */ +export function decodeResponseBody(action: string, result: any): any { + switch (action) { + case 'start_dialog_turn': { + if (result && typeof result === 'object' && 'status' in result) { + return { success: true, message: result.status === 'queued' ? 'Dialog turn queued' : 'Dialog turn started' }; + } + return result; + } + case 'list_sessions': + return unwrapArray(result, 'sessions'); + case 'list_pending_permission_requests': + case 'subscribe_permission_requests': + return unwrapArray(result, 'requests'); + case 'respond_permission_batch': + return unwrapArray(result, 'request_ids'); + case 'list_project_permission_grants': + return unwrapArray(result, 'grants'); + case 'list_project_permission_audit': + return unwrapArray(result, 'records'); + case 'git_get_branches': + return unwrapArray(result, 'branches'); + default: + return result; + } +} + +/** Maps a frontend `ImageContextData` to the schema `AgentInputAttachment`. */ +function encodeImageAttachment(image: any): any { + const metadata: Record = {}; + if (image.imagePath) metadata.imagePath = image.imagePath; + if (image.dataUrl) metadata.dataUrl = image.dataUrl; + if (image.mimeType) metadata.mimeType = image.mimeType; + if (image.metadata) metadata.metadata = image.metadata; + return { kind: 'remote_image', id: image.id, metadata }; +} + +/** Mirrors `desktop_user_message_metadata`: object-as-is, else wrapped, else empty. */ +function encodeUserMetadata(metadata: unknown): Record { + if (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) { + return metadata as Record; + } + if (metadata === undefined || metadata === null) return {}; + return { raw_metadata: metadata }; +} + +/** + * Converts the frontend `{requestId, reply: 'once'|'always'|'reject', feedback?}` + * shape to the schema `RespondPermissionMessage` / + * `RespondPermissionBatchMessage` wire shape: `{request_id, reply: {reply: + * 'once'}}` (tagged-enum form). + */ +function encodePermissionResponseBody(body: any): any { + const { requestId, reply, feedback, ...rest } = body; + const tagged: Record = { reply }; + if (reply === 'reject' && feedback) tagged.feedback = feedback; + return { ...rest, request_id: requestId, reply: tagged }; +} + +/** Unwraps a `{key: [...]}` response wrapper into the bare array the frontend expects. */ +function unwrapArray(result: any, key: string): any { + if (result && typeof result === 'object' && Array.isArray(result[key])) { + return result[key]; + } + return result; +} + interface PendingRequest { resolve: (value: any) => void; reject: (error: any) => void; @@ -122,40 +395,49 @@ export class WebSocketTransportAdapter implements ITransportAdapter { private setupMessageHandler(): void { if (!this.ws) return; - + this.ws.onmessage = (event) => { try { const message = JSON.parse(event.data); - - - if (message.id && this.pendingRequests.has(message.id)) { + + // JSON-RPC 2.0 response: carries `id` matching a pending request. + // Shape: `{ jsonrpc: "2.0", id, result | error }`. + if (message.id !== undefined && this.pendingRequests.has(message.id)) { const pending = this.pendingRequests.get(message.id)!; clearTimeout(pending.timeout); - + if (message.error) { pending.reject(webSocketResponseError(message.error)); } else { pending.resolve(message.result); } - + this.pendingRequests.delete(message.id); return; } - - - if (message.event) { - const listeners = this.eventListeners.get(message.event); + + // JSON-RPC 2.0 notification: `{ jsonrpc: "2.0", method, params }`. + // The server pushes `agent/frontendEvent` notifications carrying the + // projected frontend event name and payload, so dispatch on + // `params.event` exactly like the legacy `WsMessage::Event{event,payload}`. + if (message.method === 'agent/frontendEvent' && message.params?.event) { + const eventName: string = message.params.event; + const payload = message.params.payload; + const listeners = this.eventListeners.get(eventName); if (listeners && listeners.size > 0) { listeners.forEach(callback => { try { - callback(message.payload); + callback(payload); } catch (error) { - log.error('Error in event listener', { event: message.event, error }); + log.error('Error in event listener', { event: eventName, error }); } }); } + return; } + // Legacy `agentic://` pass-through (kept for any notification the + // server still sends with a bare `type` field in the old shape). const mappedEventName = this.mapLegacyTypeToEventName(message.type); if (mappedEventName) { const listeners = this.eventListeners.get(mappedEventName); @@ -187,28 +469,54 @@ export class WebSocketTransportAdapter implements ITransportAdapter { return `agentic://${type.trim()}`; } - + + async request(action: string, params?: any): Promise; + async request( + action: K, + params: (typeof AGENT_COMMAND_SCHEMA)[K] extends { request: infer R } ? R : undefined, + ): Promise< + (typeof AGENT_COMMAND_SCHEMA)[K] extends { response: infer S } ? S : unknown + >; async request(action: string, params?: any): Promise { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { await this.ensureConnected(); } const messageId = `msg_${Date.now()}_${++this.messageIdCounter}`; - + // Translate the frontend snake_case command to the app-server JSON-RPC + // method so agent-kernel requests reach the app-server surface in web mode. + const method = resolveWsMethod(action); + // The service layer calls `api.invoke('cmd', { request: {...} })` (Tauri + // convention). ACP `on_receive_request` deserializes the bare body, so + // unwrap the `{request}` envelope here. + const envelope = (params && typeof params === 'object' + && 'request' in params + && Object.keys(params).length === 1) + ? params.request ?? {} + : params ?? {}; + // Encode the frontend body into the app-server schema wire shape (field + // names, tagged enums, attachment projection). Mirrors the conversions the + // desktop host does in Rust (`agentic_api.rs`). + const body = encodeRequestBody(action, envelope); + return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(messageId); reject(new Error(`Request timeout: ${action}`)); - }, 30000); - - this.pendingRequests.set(messageId, { resolve, reject, timeout }); - + }, 30000); + + this.pendingRequests.set(messageId, { + resolve: (value: any) => resolve(decodeResponseBody(action, value)), + reject, + timeout, + }); + try { this.ws!.send(JSON.stringify({ - type: 'request', + jsonrpc: '2.0', id: messageId, - method: action, - params: params || {} + method, + params: body, })); } catch (error) { clearTimeout(timeout); @@ -268,5 +576,3 @@ export class WebSocketTransportAdapter implements ITransportAdapter { return this.ws !== null && this.ws.readyState === WebSocket.OPEN; } } - - diff --git a/src/web-ui/tsconfig.json b/src/web-ui/tsconfig.json index 3e5082c5a..e9e9b7fc4 100644 --- a/src/web-ui/tsconfig.json +++ b/src/web-ui/tsconfig.json @@ -36,6 +36,7 @@ "@/styles/*": ["src/component-library/styles/*"], "@/types/*": ["src/shared/types/*"], "@/utils/*": ["src/shared/utils/*"], + "@/generated/*": ["src/generated/*"], "@components/*": ["src/component-library/components/*"] } },