diff --git a/docs/architecture/acp-client-runtime-event-integration-design.md b/docs/architecture/acp-client-runtime-event-integration-design.md new file mode 100644 index 0000000000..0920a0f5e1 --- /dev/null +++ b/docs/architecture/acp-client-runtime-event-integration-design.md @@ -0,0 +1,733 @@ +# ACP Client、Runtime 事件总线与移动端支持设计 + +Date: 2026-08-20 + +Status: Design proposal. This document describes the target implementation and rollout order; it does not mean that the +ACP client, remote control plane, or HarmonyOS UI already supports every capability below. + +## 1. 结论 + +采用方案 B:**ACP 保留自己的协议执行、工具循环、权限语义和会话恢复;ACP 的桌面端观察事件进入现有 +`EventQueue`,再由既有内部订阅和 Desktop delivery pipeline 处理。** + +这次不采用方案 C。ACP client 方向是“外部 Agent 借用 BitFun 的会话壳”,不是 Runtime 调度的 native turn。 +`is_externally_projected_session` 继续成立,Runtime 不取得 ACP turn 的调度、历史分支或最终生命周期所有权。 + +事件总线整合是移动端 ACP 的数据面基础,但它本身不是完整的移动端支持。完整交付还需要远程命令、权限信箱、 +ACP 会话能力协商和 HarmonyOS UI 分阶段补齐。 + +## 2. 当前问题与已确认边界 + +### 2.1 当前出口 + +native Desktop 事件经过以下路径: + +```mermaid +flowchart LR + Runtime["Agent Runtime"] --> Queue["EventQueue"] + Queue --> Loop["event_loop_driver\n单一消费者"] + Loop -->|envelope| Router["EventRouter / internal subscribers"] + Loop -->|"TextChunk / ThinkingChunk"| Coalescer["TextChunkCoalescer"] + Loop -->|其他事件直通| Deliver["deliver_event_to_webview"] + Coalescer --> Deliver + Deliver --> Journal["SessionEventJournal"] + Deliver --> Projection["project_agentic_frontend_event"] + Projection --> WebView["Tauri transport"] + Deliver --> Peer["Peer device fanout"] +``` + +`event_loop_driver`(`lib.rs:2339`)是唯一消费者:同一批 envelope 先 `route` 给内部订阅者,再送进合批器;合批只针对 +文本类事件,其他事件穿过 `push` 直接投递并顺带 flush 掉待发文本。 + +Desktop ACP 目前在 `src/apps/desktop/src/api/acp_client_api.rs` 中直接调用 `AppHandle::emit`。因此 ACP 事件绕过 +了 `EventQueue`、`EventRouter`、`TextChunkCoalescer`、`SessionEventJournal`、RemoteSessionStateTracker 和 peer +fanout。ACP 与 native 使用了相同的若干前端事件名,但由两套手写 payload 维护。 + +### 2.2 ACP 不是 native Runtime turn + +`src/crates/assembly/core/src/product_runtime.rs` 已明确把 ACP session 视为 externally projected session: + +- Runtime 不启动或完成 ACP turn; +- Runtime 不为 ACP turn 建立 native history branch; +- ACP 现有历史不能被 `SessionManager` 以 native 模式加载,否则可能重写 session mode; +- 当前 ACP 历史仍通过兼容路径保存,不能因为接入事件总线就让 Runtime 成为第二个 transcript writer。 + +这个边界保留不变。B 只统一“事件观察和投递”,不统一“执行和所有权”。 + +### 2.3 总线订阅者的副作用 + +事件总线不是纯前端广播。当前 `CronEventSubscriber` 会把 `DialogTurnStarted/Completed/Failed/Cancelled` 当作调度 +生命周期;RemoteSessionStateTracker 也根据同样的事件更新 active turn。若 ACP 生命周期事件直接伪装成 native 事件, +会误更新 Cron 等只应处理 Runtime turn 的订阅者。 + +因此 B 必须同时引入事件来源标记,或者等价的类型化隔离。推荐把 +`AgenticEventOrigin::{NativeRuntime, ExternalAcp}` 放在 `AgenticEventEnvelope` 上,而不是给每个 +`AgenticEvent` variant 增加重复字段。这样旧 event 构造点保持不变,queue/router/journal 仍能看到统一的 event, +subscriber 可以按 envelope origin 做隔离,而不是再建一套 ACP delivery bus。 + +### 2.4 当前 envelope 到不了消费者 + +上一节的方案有一个必须先解决的前提:**envelope 在到达消费者之前就已经被拆开了**。 + +- `EventQueue::enqueue(event, priority)`(`event_queue.rs:259`)在内部构造 envelope,调用方没有注入 origin 的入口; +- `EventRouter::route(envelope)`(`event_router.rs:37`)拿到的是完整 envelope,但 trait 只暴露 + `on_event(&self, event: &AgenticEvent)`(`event_router.rs:14`),origin 恰好在这一跳被丢弃; +- Desktop delivery 侧同理:`event_loop_driver` 的 `D: FnMut(AgenticEvent)`(`lib.rs:2339`)、 + `deliver_event_to_webview(transport, event, journal)` 和 `SessionEventJournal::record(&AgenticEvent)` + (`session_event_journal.rs:244`)都只见裸 event。 + +所以「订阅者按 origin 隔离」不是加一个字段就能成立的,它需要一段明确的管道改造,见 5.3。低估这一点的直接后果是 +实现时退回到「把 origin 塞进每个 `AgenticEvent` variant」——而那正是 2.3 否掉的做法。 + +### 2.5 存在两套 router 喂法 + +改动 5.3 的管道前必须知道:`EventRouter` 目前有两个互不相同的驱动方式,而 **Desktop 用的不是 core 那套**。 + +| 宿主 | 初始化 | router 由谁喂 | 顺序性质 | +|---|---|---|---| +| Desktop | `src/apps/desktop/src/lib.rs` 的**同名私有** `init_agentic_system`,自建 queue/router,六个订阅者在 Desktop 初始化路径注册(含 `AcpDurableProjectionWriter`) | `event_loop_driver` 的 `dequeue_configured_batch` → `event_router.route(envelope)` | **priority heap**,跨优先级会重排 | +| core | `agentic/system.rs:69` 的 `init_agentic_system_for_profile_with_runtime_ownership` | `event_queue.subscribe()` 的 broadcast 循环(`system.rs:142-158`) | `broadcast::Sender` **严格 FIFO**,不会重排;失败模式是 `RecvError::Lagged` 丢事件 | + +两条路径都要接受 5.3 的改造。P1 只验证 Desktop 一条会留下 core 宿主的空洞。 + +## 3. 目标与非目标 + +### 3.1 目标 + +1. Desktop ACP 的共享事件进入既有 `EventQueue`,且每个事件只发布一次。 +2. 由 `frontend_projection.rs` 维护 canonical frontend envelope;ACP API 删除同名的手写 `AppHandle::emit`。 +3. ACP 获得 journal cursor、snapshot/backfill、文本合批、peer fanout 和 RemoteSessionStateTracker 更新。 +4. 保留 ACP 的协议解码、stream tracker、工具循环、权限响应、模型/上下文/恢复策略。 +5. 手机和其他 Remote client 能够识别 ACP session,不再把 ACP 当 native agentic session 静默执行。 +6. 远程控制在能力协商后逐步支持 ACP 的发送、取消、权限响应、session options 和 ACP metadata。 +7. 所有断线恢复通过既有 cursor/version、持久化 projection 和幂等命令完成。 + +### 3.2 非目标 + +- 不把 ACP client 接入 native scheduler、ConversationCoordinator 的 turn admission 或 SessionManager 生命周期。 +- 不建立方案 C 所需的 runtime-side ACP turn registry。 +- 不把 ACP permission ID/option 语义强行转换成 native tool permission ID。 +- 不让 HarmonyOS 直接连接 ACP 进程;ACP 进程仍运行在 Desktop/CLI/目标 Runtime 所在机器。 +- 不为兼容旧版本而把 unknown ACP command 静默降级为 native `agentic`。 + +## 4. 目标架构 + +```mermaid +flowchart TB + ACPProtocol["ACP protocol/process\nbitfun-acp"] --> Stream["AcpClientStreamEvent"] + Stream --> DesktopAdapter["Desktop ACP event adapter\nmap + origin + sequence"] + DesktopAdapter --> Publisher["AcpEventPublisher\nordered unbounded channel + watermark"] + Publisher --> Queue["EventQueue"] + Queue --> Subscribers["EventRouter subscribers"] + Queue --> Delivery["Desktop queue consumer"] + Delivery --> Journal["SessionEventJournal"] + Delivery --> Projection["frontend_projection"] + Projection --> Local["Desktop / Web UI"] + Subscribers --> Tracker["RemoteSessionStateTracker"] + Tracker --> Poll["Remote PollSession"] + Poll --> Mobile["HarmonyOS / mobile web"] + + Remote["RemoteCommand"] --> Control["Remote ACP control adapter"] + Control --> ACPProtocol + Control --> Mailbox["ACP permission mailbox"] +``` + +`bitfun-acp` 继续只产生协议层的 `AcpClientStreamEvent`。事件到 `AgenticEvent` 的转换放在 Desktop ACP adapter, +因为它同时知道 BitFun session/turn identity、event queue 和 Desktop delivery profile;这避免 `bitfun-acp` 依赖 +Desktop/Tauri 或向上依赖 Product Assembly。 + +## 5. 事件契约 + +### 5.1 事件来源 + +在 `src/crates/contracts/events` 增加来源枚举,并把它加入 `AgenticEventEnvelope`。建议字段名称为 +`origin`,默认值为 `NativeRuntime`,以保持旧 event envelope 和旧 JSON 的兼容性。 + +需要携带来源的事件: + +| 事件 | ACP 映射 | 备注 | +|---|---|---| +| `SessionCreated` | 创建 ACP flow session record 后发布 | 不带 turn;用于远程 session list 的刷新。 | +| `DialogTurnStarted` | `start_acp_dialog_turn` 开始时发布 | `turn_index` 使用 ACP projection 的稳定序号,不宣称 Runtime 已 admission。 | +| `ModelRoundStarted` | ACP round tracker 产生 round start | 使用 `ModelRoundIdentity::External { provider: "acp", client_id, .. }`,见 5.5;不填 native model config 字段。 | +| `TextChunk` | `AgentText` | 保留 session/turn/round identity。 | +| `ThinkingChunk` | `AgentThought` | 保留 thinking end 语义。 | +| `ToolEvent` | `ToolEvent(ToolEventData)` | 复用已有 shared tool event contract。 | +| `ModelRoundCompleted` | 下一 round 或 terminal event 到来前关闭当前 round | 由 Desktop adapter 维护工具计数。 | +| `DialogTurnCompleted` | ACP `Completed` | `total_rounds/total_tools/duration_ms` 由 adapter 计算;不要填 native execution facts。 | +| `DialogTurnCancelled` | ACP `Cancelled` | 取消语义仍由 ACP client 负责。 | +| `DialogTurnFailed` | stream/timeout/protocol error | `error_category` 只在能可靠映射时填写。 | + +`frontend_projection.rs` 不展示 `origin`,它是内部 subscriber 的路由事实,不是 UI 文案字段。旧 payload 继续可被 +旧客户端反序列化;新增 envelope 字段使用默认值(`AgenticEventEnvelope` 已经 `Serialize, Deserialize`, +`agentic.rs:591`,所以 `#[serde(default)]` 足以让旧快照按 `NativeRuntime` 读回)。若 journal/backfill 需要保留来源, +来源必须随 envelope 一起持久化,不能从 event payload 猜测。 + +注意 journal 并不给上表所有事件分配 cursor:`SessionEventJournal::record` 在 `event_turn_id(event).is_none()` 或事件 +是 `ToolEventData::StreamChunk` 时直接返回 `None`(`session_event_journal.rs:254-264`)。因此 `SessionCreated` 和 tool +stream chunk 没有 cursor,不参与 backfill——这是既有 native 行为,ACP 保持一致即可,但验收文案不能宣称「所有 ACP 事件 +都可按 cursor 补齐」。 + +### 5.2 ACP 专属 metadata 事件 + +四类当前由 ACP 手写 emit 的事件没有 native `AgenticEvent` 对等物:context usage、available commands、plan、 +config options。它们不能被丢弃,也不应重新引入绕过总线的 `AppHandle::emit`。在 events contract 增加四个类型化 +的 ACP metadata event,或一个带强类型 payload 的 `ExternalSessionMetadataUpdated`;推荐四个显式 variant,原因是 +移动端能力和持久化策略不同: + +- `AcpContextUsageUpdated { session_id, turn_id, client_id, used, size, cost }` with envelope origin +- `AcpAvailableCommandsUpdated { session_id, client_id, commands }` with envelope origin +- `AcpPlanUpdated { session_id, turn_id, client_id, entries }` with envelope origin +- `AcpSessionOptionsChanged { session_id, client_id }` with envelope origin + +这些事件由 projection 映射到现有 `agentic://acp-*` 名称,并可被 RemoteSessionStateTracker 选择性 materialize。 +它们不参与 native turn settlement,也不触发 Cron。 + +### 5.3 origin 的传递路径 + +按 2.4,origin 要真正可用需要改动三处,且必须作为 P1 的显式工作项,不能顺手做: + +1. **入队**:`EventQueue` 增加接受 origin 的入队入口(新增参数或 `enqueue_*_with_origin` 变体),三个既有入队方法 + `enqueue` / `enqueue_with_legacy_dequeue_ack` / `enqueue_with_guaranteed_legacy_storage` 都要能表达来源,默认 + `NativeRuntime`,现有调用点不改。 +2. **路由**:`EventRouter::route` 已经持有 envelope,只需把 trait 扩成默认转发,避免改动既有 impl 和它们的测试 + 替身: + + ```rust + #[async_trait] + pub trait EventSubscriber: Send + Sync + 'static { + async fn on_event(&self, event: &AgenticEvent) -> EventSubscriberResult; + + /// 默认转发到 on_event;只有需要按来源隔离的订阅者才覆写。 + async fn on_envelope(&self, envelope: &AgenticEventEnvelope) -> EventSubscriberResult { + self.on_event(&envelope.event).await + } + } + ``` + + `route` / `route_batch` 改调 `on_envelope`。按 5.4 的表,`CronEventSubscriber` 和 `AcpDurableProjectionWriter` 覆写 + `on_envelope`:前者只收 `NativeRuntime`,后者只收 `ExternalAcp`。 +3. **Desktop delivery**:`event_loop_driver` 的 deliver 闭包与 `deliver_event_to_webview` 目前只接 `AgenticEvent`。 + 仅当 journal/backfill 确实要持久化来源时才把签名改成 envelope;如果不需要,就明确写下「live delivery 不感知 + origin」,不要留下模棱两可的中间状态。 + +`frontend_projection` 始终不接触 origin,这一条不受本节影响。 + +### 5.4 订阅者规则 + +全量 `EventSubscriber` 实现有 6 个,逐个定性如下: + +| 订阅者 | 消费的事件 | ACP 接入后的要求 | +|---|---|---| +| `CronEventSubscriber`(`service/cron/subscriber.rs`) | `DialogTurnStarted/Completed/Failed/Cancelled` | **必须覆写 `on_envelope` 并只处理 `NativeRuntime`。** ACP lifecycle 会直接污染它。 | +| `AcpDurableProjectionWriter`(`src/apps/desktop/src/runtime/acp_projection_writer.rs`) | ACP session/turn/round/tool 投影 | **必须覆写 `on_envelope` 并只处理 `ExternalAcp`。** `on_event` 是空 no-op,避免误把 native turn 写成 ACP transcript。 | +| `CoreRemoteSessionStateTrackerSubscriber`(`service_agent_runtime.rs`) | 全量观察 | 两类来源都处理;remote tracker 的职责是观察而不是调度。 | +| `TokenUsageSubscriber`(`service/token_usage/subscriber.rs`) | token usage | 依赖「ACP 不发 `TokenUsageUpdated`」。 | +| `SessionContextUsageSubscriber`(`agentic/session/context_usage.rs`) | `TokenUsageUpdated`、`ContextCompressionCompleted` | 同上;ACP 的用量走 `AcpContextUsageUpdated`,不落到这里。 | +| `ThreadGoalTokenSubscriber`(`agentic/goal_mode/token_subscriber.rs`) | 仅 `TokenUsageUpdated` | 同上,且污染后果是 thread goal **计费**错误。 | + +后三个订阅者不需要 origin guard,但它们的安全性**完全依赖于 7.2 那条「不合成 `TokenUsageUpdated`」**。这是隐式耦合: +一旦有人觉得「ACP 也该有 token 统计」顺手补上,三个订阅者会同时被外部 agent 的数据污染,而且没有任何 guard 会拦。 +因此这条约束写进 13 的检查线,而不是只留在映射表的备注里。 + +测试必须覆盖:发布一组 `ExternalAcp` lifecycle 后,Cron job state 不变化;RemoteSessionStateTracker 正常推进。 + +### 5.5 已定:round 的 model identity 用类型化枚举 + +`ModelRoundStarted` 现在的两个模型字段是 **`String`,不是 `Option`,也没有 serde default**(`agentic.rs:285-288`): + +```rust +/// Resolved `AIModelConfig.id` used for this round. +model_config_id: String, +/// Provider model name sent on the request. +effective_model_name: String, +``` + +它们的语义是 native model usage 的一部分。ACP 的 round 从来不解析 BitFun 的 `AIModelConfig`,把 `client_id` 或 adapter +名填进 `model_config_id` 会让前端和后续统计误以为那是真实 config;填空串更糟——字段不是 `Option`,下游会当作"一个真实 +但空白的 config"。把两个字段改成 `Option` 也不够:那样「native 但没解析出 config」和「外部 agent」在类型上无法区分,还 +多出「两个都填」「两个都不填」这类非法状态。 + +**决定:把两个字段替换为一个枚举,让非法状态不可表示。** + +```rust +/// Which model drove this round. Native rounds resolve a BitFun +/// `AIModelConfig`; externally projected rounds (ACP) never do. The two cases +/// are variants rather than optional fields: a round can be neither both nor +/// neither. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum ModelRoundIdentity { + Native { + /// Resolved `AIModelConfig.id` used for this round. + model_config_id: String, + /// Provider model name sent on the request. + effective_model_name: String, + }, + External { + /// Owning provider, currently only `"acp"`. + provider: String, + /// Adapter identity within the provider, e.g. `"gemini"`. + client_id: String, + /// Model id reported by the external agent, when it reports one. + #[serde(default, skip_serializing_if = "Option::is_none")] + model_id: Option, + /// Display name reported by the external agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, + }, +} +``` + +`ModelRoundStarted` 以 `identity: ModelRoundIdentity` 取代原来的两个字段。 + +**projection 保持 native 线上契约逐字节不变**(`frontend_projection.rs:121-140`): + +- `Native` 分支照旧输出 `modelConfigId` / `effectiveModelName`; +- `External` 分支输出 `externalModel: { provider, clientId, modelId?, displayName? }`,并**省略**那两个 native 键。 + +省略是安全的,前端**已经**按可选处理:`modelConfigId?: string`(`flow-chat.ts:206-207`)、 +`modelConfigId: string | undefined`(`EventHandlerModule.ts:648`),且已有断言两者为 `undefined` 的测试 +(`EventHandlerModule.test.ts:1145-1146`)。因此老客户端读到 ACP round 的表现,与读到一个本来就没带这两个键的旧事件一致。 + +**改动面(实测,不大):** + +| 位置 | 改动 | +|---|---| +| `round_executor.rs:352`、`coordinator.rs:4159` | 仅有的两个 native 构造点,改填 `ModelRoundIdentity::Native` | +| `frontend_projection.rs:121-140` | 拆成两个分支,native 输出不变 | +| `session_event_journal.rs:504-508`、`remote_connect.rs:3371` | 只匹配 `round_id` / `round_index` 且带 `..`,无需改动 | +| 前端 | 新增 `externalModel` 的可选消费;不改既有字段 | + +其余 17 处 `ModelRoundStarted` 引用都是带 `..` 的模式匹配,不涉及这两个字段。journal 的 tail 是内存结构 +(`push_tail`,`session_event_journal.rs:104-112`),不落盘,所以没有历史事件快照需要迁移;需要考虑线上兼容的只有 CLI +dispatch 把事件序列化进 job status 的那条路径,而 native 分支输出不变意味着那条路径也不受影响。 + +## 6. Phase 0:先堵住远程误路由 + +这是独立于 B 的高风险修复,应先合入或与 B 同批发布。 + +当前 `RemoteCommand::SendMessage` 的 `agent_type` 缺省会走 native `agentic`;HarmonyOS 的 session type 归一化也会把 +未知类型默认为 `agentic`。对 ACP session 这会造成“发送成功但执行了错误 Agent”的静默错误。 + +实现要求: + +1. 在远程 session metadata 增加可选 `session_kind`/`capabilities`,至少区分 `native` 和 `acp`。 +2. `SessionInfo`、`InitialSync`、`ListSessions` 和 session detail 都返回该字段;旧客户端忽略新字段即可。 +3. `submit_remote_dialog` 在发现 ACP session 时,若 ACP remote capability 未协商,返回明确的 + `unsupported_remote_capability`,不得回退到 native。 +4. HarmonyOS `RemoteSessionManager` 和 `RemoteCommandFactory` 在发送前按 `session_kind` 选择 ACP 命令;旧桌面收到 + 新 ACP command 时返回可识别的 unsupported response。 +5. 旧客户端继续发送 native `send_message` 时,Desktop 对 ACP session 返回 fail-loud 错误,错误文本和结构化 code + 都表明“ACP session requires ACP control capability”。 + +不使用包版本相等判断能力;使用命令/响应 capability negotiation。新字段必须有 serde default,老 session record +缺字段时按“未知能力”处理,而不是按 native 处理。 + +## 7. Phase 1:B 的 Desktop 事件总线整合 + +### 7.1 注入方式 + +`AcpClientService` 不能直接持有 `AppHandle` 或 core queue。保持它的 callback API 和协议职责不变,在 Desktop +adapter 中增加 `AcpEventPublisher`: + +1. `init_agentic_system` 已先创建 `EventQueue`;把 `Arc` 通过 `AppState` 或专用 Desktop delivery + context 注入 ACP API。 +2. ACP stream callback 只做同步的 typed mapping,并把带 `ExternalAcp` origin 的 envelope 写入**单条 + unbounded、保持顺序的 channel**。有界水位只作用于 BestEffort:超过水位时丢掉 stream chunk, + Guaranteed/Fence **永不丢、永不 `blocking_send`**。同步 `send` 不能进 Tokio runtime 的 + `blocking_send`——那会在 `#[tauri::command] async fn` 和 stream 终态回调里无条件 panic。拆两条通道会让 + 终态越过未消费的 `TextChunk`,正好破坏 fence。 +3. publisher worker 串行调用 queue 的 enqueue API;生命周期事件使用 guaranteed legacy storage 策略,stream + chunks 使用普通 best-effort 策略,具体优先级沿用 `AgenticEvent::default_priority`。 +4. 启动、每个 stream event、round close、terminal event 均走同一个 publisher,禁止一部分走 queue、一部分走 + `AppHandle::emit`。 +5. queue 的 priority heap 先比 priority 再比 timestamp(`Ord for AgenticEventEnvelope`,`agentic.rs:613-617`), + 所以只有**跨优先级**才会重排。按 `default_priority`(`agentic.rs:696-731`)分开看: + - `DialogTurnCompleted` 是 `Normal`,与 `TextChunk`/`ThinkingChunk`/`ModelRoundStarted` 同级,堆内平级按时间戳 + 退化为 FIFO,**正常完成路径不需要额外 fence**; + - `DialogTurnCancelled` / `DialogTurnFailed` 是 `Critical`,**会**越过已入堆的同 turn stream 事件,必须加 fence。 + + fence 不要新建机制。native 已有现成范式:`enqueue_with_legacy_dequeue_ack` 的契约就是「await ack 后该事件已属于 + 当前投递批次,之后更高优先级事件无法在 priority heap 中越过它」(`event_queue.rs:275-288`),而 + `coordinator.rs:7010-7020` 发布 `DialogTurnRecovered` 时正是靠**显式降级到 `Normal` + ack** 来与既有 normal 数据 + 保序。ACP 的 cancel/fail 终态照抄这一组合即可。 + + **但要写明 ack 的保证范围,它不是全局顺序保证。** `enqueue_internal` 先写 legacy queue、随即 broadcast,ack 要到 + dequeue 时才触发(`event_queue.rs:420-451`)。因此: + - Desktop 的 router 和 WebView delivery **都**由 `dequeue_configured_batch` 驱动(2.5),ack fence 同时覆盖这两条, + 这是 P1 实际要保证的场景; + - core 的 broadcast 驱动 router(`system.rs:142`)在 ack 之前就能观察到事件,但 broadcast 是严格 FIFO,本来就不会 + 发生 priority 重排,**不需要 fence**;它真正的失败模式是 `RecvError::Lagged` 丢事件,那是容量问题,加排序语义解决 + 不了; + - 不要把这个 ack 描述成所有 `EventSubscriber` 的通用顺序保证。 +6. CLI standalone ACP service 不注入 Desktop publisher,保留 CLI 自己的输出和现有生命周期。 + +publisher 必须保证同一 session/turn 内的入队顺序;不能对每个 event 独立 `tokio::spawn`,否则 terminal event 可能早于 +text/tool event 到达 queue。入队顺序之外还必须由上面第 5 条的 fence 对 priority reordering 做约束。 + +publisher **不要自建文本合批**。`event_loop_driver` 的同一个消费者既 `route` 给订阅者、又把事件送进 +`TextChunkCoalescer`(`lib.rs:2390-2400`),合批只作用于 `TextChunk`/`ThinkingChunk`,其他事件从 `push` 直接穿出并 +顺带 flush 掉待发文本。也就是说 ACP 只要产出正确的 `TextChunk`,就自动获得与 native 相同的自适应合批。合批 key 含 +`resolve_attempt_token(attempt_id, attempt_index)`(`event_coalescer.rs:123-133`);ACP 没有 attempt 语义,两个字段 +统一填 `None` 即可得到稳定 key,**不要为了"看起来完整"编造 attempt id**,那会让同一 round 的 chunk 分裂成多个 key +而失去合批。 + +### 7.2 ACP stream 映射 + +| `AcpClientStreamEvent` | `AgenticEvent` | 适配器状态 | +|---|---|---| +| `ModelRoundStarted` | `ModelRoundStarted` | `current_round_id`、round index、是否有 tool call;identity 填 `External`(5.5)。 | +| `AgentText(text)` | `TextChunk` | 必须已有 current round,否则记录 protocol error 并 fail turn。 | +| `AgentThought(text)` | `ThinkingChunk` | `is_end` 按 ACP stream stop/flush 规则生成。 | +| `ToolEvent(data)` | `ToolEvent` | `round_id` 来自 current round;ToolEventData 不复制。 | +| `ContextUsageUpdated` | `AcpContextUsageUpdated` | 不合成 `TokenUsageUpdated`。 | +| `AvailableCommandsUpdated` | `AcpAvailableCommandsUpdated` | 供 desktop/mobile capability view 使用。 | +| `PlanUpdated` | `AcpPlanUpdated` | 不映射为 native workflow state。 | +| `ConfigOptionsUpdated` | `AcpSessionOptionsChanged` | options 内容由 ACP options API 按需读取。 | +| `Completed` | close round + `DialogTurnCompleted` | 填 adapter 可证明的统计。 | +| `Cancelled` | close round + `DialogTurnCancelled` | 不调用 native scheduler cancel。 | + +`create_acp_flow_session` 也发布 `SessionCreated`。`start_acp_dialog_turn` 的开头发布 +`DialogTurnStarted`,而不是直接 emit 前端事件。若 ACP client 初始化、timeout 或 callback mapping 失败,必须发布 +`DialogTurnFailed`,并保证一个 turn 最多一个 terminal lifecycle event。 + +### 7.3 前端 projection 与远程投影 + +`deliver_event_to_webview` 是唯一的 Desktop live delivery owner: + +1. journal 先记录可投影事件并分配 cursor; +2. `project_agentic_frontend_event` 生成 canonical name/payload; +3. cursor 附加到 payload; +4. transport emit; +5. peer fanout。 + +ACP API 删除 `app_handle.emit("agentic://...")` 的重复路径。`renderHints.disableExploreGrouping` 如果仍是 ACP 专属 +UI 语义,应作为 typed projection metadata 增加到 canonical contract,不能重新在 ACP API 拼 JSON。 + +顺带被改善的一项,但**不是无条件的**。`handle_remote_poll_command` 的无变化短路要求四项同时成立 +(`remote_connect.rs:1472-1478`): + +```rust +since_version == current_version && since_version > 0 + && !model_catalog_delta.changed && !persistence_dirty +``` + +且 `needs_persistence = since_version == 0 || persistence_dirty`(`:1480`)。ACP session 的 tracker version 目前恒为 0, +因此**每次 poll 都全量重读持久化历史**。准确的收益表述是:**当 ACP tracker 已推进 version、且当前 projection 已被持久化 +清理后,后续无变化 poll 才能命中短路;活动中的 dirty turn 仍会走持久化路径。** 验收按这个措辞写,不要宣称"接入后 poll +不再读盘"。 + +### 7.4 持久化边界 + +B 会让 journal cursor、remote tracker 和 live projection 工作,但不自动解决 ACP transcript 的 durable writer。 +Phase 1 不得把 ACP 事件喂给 native `SessionManager` 以“顺便持久化”。应先保留当前兼容 persistence path,并增加一个 +明确的 ACP projection writer: + +- writer 消费已排序的 ACP event projection; +- 以 session/turn 幂等写入 user/assistant/tool transcript; +- 只更新 externally projected session 的持久记录,不加载成 native Runtime session; +- **进行中 turn 在结构性边界(`DialogTurnStarted` / `ModelRoundStarted` / `ToolEvent`)无条件 + checkpoint 为 `InProgress`;`TextChunk` / `ThinkingChunk` 按时间下限(默认 ≥2s)或累计字节阈值 + (默认 ≥4KiB)节流 checkpoint。`turn_index` 在 turn 开始时解析一次并缓存在 draft 上,禁止每个 + chunk 全量 `load_session_turns`。恢复语义要的是足够新,不是每个 token 都不丢——中断态本来就是 + `Cancelled` + `Interrupted`。Desktop 退出时把剩余 draft flush 成同样中断态;重启后 `SessionCreated` + 把盘上遗留的 `InProgress`(且当前进程没有对应 draft)恢复为中断态。禁止只把 draft 留在内存里、终态才落盘; +- 落盘成功后再丢 draft。**终态 / interrupted** persist 失败必须保留 draft 供重试,并标记 + `history_snapshot_required`;中途 checkpoint 瞬时失败只记日志、保留 draft,**不得**把整个 session + 打成历史不可读; +- terminal / interrupted 后发布/记录 `SessionHistoryChanged` 或现有 durable fence,使 tracker 知道何时可以清理 dirty state; +- writer 终态故障时 remote poll 返回明确的 `history_snapshot_required`,不能声称持久化成功。 + +如果当前 Web UI 仍是唯一可用 writer,Phase 1 的验收只能声明“live event + journal backfill”,不能声明“断开桌面后 +完整 transcript 已 durable”。 + +## 8. Phase 2:远程 ACP 控制面与权限 + +### 8.1 命令族 + +不要把所有 ACP 行为塞进 native `SendMessage` 或 `ConfirmTool`。新增带 capability gate 的窄命令,名称建议保持 +`acp_*`,由 `RemoteCommand` 统一承载: + +| 命令 | 作用 | 失败语义 | +|---|---|---| +| `AcpSendMessage` | 向已存在 ACP session 发 prompt | session/client 未运行或能力未协商时结构化错误。 | +| `AcpCancelTurn` | 请求 ACP cancel | 幂等;已结束返回 terminal/stale,而不是 native no-running-task。 | +| `AcpGetOptions` | 读取 ACP config/model options | 返回 ACP typed options;不伪造 native model catalog。 | +| `AcpSetOption` | 设置 ACP config option | 复用 ACP client validation;重试使用 request id。 | +| `AcpGetCommands` | 获取 available commands | 返回最后已知 snapshot,并携带 version。 | +| `AcpGetPlan` | 获取当前 plan | 仅 ACP session 支持。 | +| `AcpPermissionRespond` | 回复 ACP permission | 使用 ACP `permission_id + option_id`,不接受 native tool id 代替。 | + +命令响应必须带 `session_id`、capability/version 和可重试分类。Desktop 负责查找 ACP client/session,手机不接触 ACP +process 或 ACP protocol transport。 + +### 8.2 权限 mailbox + +ACP 当前通过 `backend-event-acppermissionrequest` 和 `pending_permissions` 等待回复;这是 desktop local event, +手机无法直接消费。目标结构: + +1. ACP permission request 先进入 Desktop-owned durable/in-memory mailbox,带 `permission_id`、session、tool call、 + options、created_at、expiry。 +2. Desktop local UI 和 Remote Poll 都从同一 mailbox view 读取;不能为手机复制一套权限状态机。 + 本地 Web UI 可通过 `list_acp_pending_permissions` 在刷新 / hydrate 后回读 pending,不能只依赖 + `backend-event-acppermissionrequest` 的瞬时 emit。 +3. `AcpPermissionRespond` 使用 option ID 幂等提交;重复提交返回 already resolved/expired。 +4. disconnect 不清除 pending request;超时由 ACP client 的既有 timeout 语义收敛为 cancelled。 +5. native `ConfirmTool/RejectTool` 只处理 native permission,若 target 是 ACP session 返回 unsupported,而不是 + 尝试转换 ID。 + +## 9. Phase 3:HarmonyOS 支持 + +HarmonyOS 只做 Remote surface,不执行 ACP。UI 依赖 server 返回的 session kind/capability,不根据字符串前缀猜测。 + +### 9.1 会话列表和路由 + +已有判定(P0,勿推倒重写): + +- `RemoteSessionKindPolicy.ets`:未知 kind → `unknown`(不是 native);`hasAcpRemoteControl` / + `canSendNative` / `resolveAgentType`。 +- `RemoteCommandFactory.sendForSession`:ACP → `acp_send_message`(带 wire `request_id`)。 +- `RemoteSessionManager.assertCanSend`:无 `acp_remote_control` 时 fail-loud + (`remote.session.acpControlRequired`)。 +- 单测:`TransportAndGeneralChatUnit.test.ets` 覆盖命令形状与「ACP 不归一成 native code」。 + +仍待补齐: + +- ~~ACP session 在列表/detail 中的 UI 呈现(类型标签、可观察但不可发送)。~~(已做) +- 旧 host vs 参数错误:Rust `parse_remote_command` 已拆 unknown name / + `invalid_acp_command_params`;手机 `classifyAcpCommandFailure` 按 + `resp`/`code`/`commandCmd` 分类(真旧 host = ACP 发送后无 code 的非结构化 error)。 + §9.3 真机验收前清单保持未勾。 +- `RemoteSessionManager` 的 version/cursor 继续使用既有 `PollSession`;ACP metadata 使用各自版本字段,避免覆盖 + message snapshot version(§9.2 projection 一并验证)。 +- ~~UI 重试发送时复用同一 `request_id`。~~(发送意图层 mint,气泡 retry 带回) + +### 9.2 会话页 + +第一版只要求: + +- 展示 ACP text/thinking/tool/plan/commands/options 的已知 projection; +- 展示 active turn、cancel、permission pending; +- 断线后按 cursor/version replay,必要时请求完整 history snapshot; +- 对不支持的 ACP 操作显示结构化错误。 + +模型选择、native permission mode、native mid-run queue 等控件不应在 ACP session 中出现,除非 ACP capability 明确 +提供等价操作。 + +### 9.3 远程场景要求 + +| 场景 | 本设计要求 | +|---|---| +| Remote control | ACP 命令和权限 mailbox 可由手机操作;不能依赖 Desktop 窗口保持打开。 | +| Peer Device Mode | 若 ACP session 通过 peer host 观察,仍复用 canonical projection 和 device fanout;禁止另发 ACP 私有 UI event。 | +| Remote workspace | ACP cwd/远程连接身份由 Desktop ACP client owner 解析;手机传 workspace identity,不传控制器本地路径语义。 | +| Detached dispatch | 本设计不把 ACP client session 变成 detached job;若未来需要,另建 dispatch capability 和持久 job owner。 | + +## 10. 兼容与升级 + +1. 新增 serde 字段全部提供默认值;旧 session metadata 缺 `session_kind` 时按 unknown 处理。 +2. 新 ACP `RemoteCommand` 不依赖 `#[serde(other)]` 猜测成功;旧 host 返回结构化 unsupported 或可识别错误。 +3. ACP client/service 的持久 session record 继续容忍旧字段,不能因新 projection 解析失败而删除记录。 +4. 远程端发送前必须检查 host capability;没有 capability 时禁用入口或返回 fail-loud 错误。 +5. 事件 envelope origin 缺失时按 `NativeRuntime` 反序列化,避免旧 native event snapshot 无法读取;新 ACP envelope + 永远显式写 `ExternalAcp`。 +6. 新 projection writer 必须按 `(session_id, turn_id, event_id)` 或等价稳定键幂等,避免 Desktop 重启/Remote retry + 造成重复 transcript。 + +## 11. 分阶段实现清单 + +### P0:风险隔离 + +- [x] 为 remote session metadata 增加 `session_kind` 和 capability facts。 +- [x] ACP session 收到 native `SendMessage` 时 fail-loud。 +- [x] HarmonyOS 不再把未知 ACP 类型归一化成 `agentic`。 +- [x] 增加旧 host/旧客户端的 unsupported contract tests。 + +### P1:事件数据面(方案 B) + +- [x] 在 events contract 增加 `AgenticEventOrigin` 和 ACP metadata events。 +- [x] **打通 origin 传递路径(见 5.3):queue 入队入口、`EventSubscriber::on_envelope` 默认方法、`route`/`route_batch` + 改调。** 这一项独立于 ACP mapping,先做完再接 publisher,否则 origin 会在实现中被塞进 event variant。 +- [x] 确认改造同时覆盖 Desktop 私有 `init_agentic_system`(`lib.rs:1960`)和 core `system.rs:69` 两条 router 喂法(2.5)。 +- [x] 实现 5.5 的 `ModelRoundIdentity` 枚举:替换 `ModelRoundStarted` 字段、改两个 native 构造点、projection 拆分支、 + 前端消费 `externalModel`。这一项与 origin 管道同属契约切片,应在接 ACP publisher 之前完成。 +- [x] 更新 canonical frontend projection,保留现有 event names/payload compatibility。 +- [x] 实现 Desktop `AcpEventPublisher`,单通道顺序、单写入 `EventQueue`;水位只丢 BestEffort;cancel/fail 终态用 `Normal` 优先级 + ack fence。 +- [x] 将 ACP stream mapper 从手写 emits 中抽出,删除 ACP API 的重复 `AppHandle::emit`。 +- [x] 更新 native-only subscribers 的 origin guard;tracker 同时支持 native/ACP。 +- [x] 为 Desktop 注入 queue/publisher;CLI 不注入 Desktop delivery。 + +### P1b:durable projection + +- [x] 明确 ACP transcript writer 的 owner 和持久字段版本。 +- [x] 完成 terminal fence、结构性边界 + 节流 InProgress checkpoint / 退出 flush / 重启 recover、snapshot-required 语义。 +- [x] 证明 externally projected session 不会被 SessionManager native load 改写。 + +### P2a:命令族与契约 + +- [x] 增加 ACP command family(含 `request_id`)和 capabilities response。 +- [x] 为 send/cancel/options/commands/plan/permission 增加幂等 request id 和错误分类。 +- [x] `AcpPermissionRespond` 签名钉死为 `permission_id + option_id`;P2a 仍可先 unsupported,mailbox 在 P2b 接上后按 option_id 提交。 +- [x] native `ConfirmTool`/`RejectTool` 打到 ACP session / ACP permission id 时 fail-loud,不转换 ID。 +- [x] 老 host / 新 host 双向 unsupported 与 parse 契约测试。 + +### P2b:权限 mailbox 与 surface 兼容 + +- [x] 把 ACP permission request 接入 Desktop-owned / shared Remote mailbox。 +- [x] `AcpPermissionRespond` 按 permission_id + option_id 幂等提交;option_id 必须属于 pending.options;断线不清 pending。 +- [x] Desktop 本地 UI 可通过 `list_acp_pending_permissions` 在刷新 / hydrate 后回读 mailbox(不只依赖 emit)。 +- [x] mobile-web / HarmonyOS 对未知 `RemoteResponse` variant 安全:二者把 `resp` 当松散字符串定点比对,不穷举 union。证据: + - `src/mobile-web/src/services/RemoteSessionManager.ts`(`resp: string`;仅 `=== 'error'` 判失败) + - `src/apps/mobile/harmonyos/.../RemoteModels.ets`(`resp?: string`)与 `RelayHttpClient.ets`(仅 `=== 'error'`) + - P2a 对 ACP session 的 native fail-loud 返回 `RemoteResponse::Error`,mobile-web 会显示。 +- [x] IM bot:resume 列表按 `provider=acp` / `session_kind=acp` 过滤,避免 ACP 会话进入 native `SessionManager` 发送路径;远端 `send_message` 按 `resp === "error"` 分流,禁止吞掉 fail-loud 假成功。本地守卫 fail-closed;远端整页 ACP 时按**连续 skip 计数**自动翻页(上限 5 次 relay 往返),触顶后交回用户回复 0 继续。接线决策单测在 `command_router` 的 `acp_wiring_tests`;策略纯函数在 `acp_bot_policy.rs`。 + +### P2:远程控制与权限(总览) + +- [x] P2a 命令族与契约(见上)。 +- [x] P2b 权限 mailbox 与 surface 兼容(见上;清单项拆开勾选,不做半勾润色)。 + +### P3:HarmonyOS + +盘点:§9.1 的 kind/capability 判定与 `acp_send_message` 路由已在树上(见 9.1「已有判定」);P3 不重写该层, +先补契约缺口与 UI 呈现,再做 event projection。 + +- [x] ACP `acp_send_message` 命令体接通 wire `request_id`(字段存在;`_request_id` 仍仅客户端日志)。 +- [x] 发送意图层 mint + 乐观消息持有 `request_id`;失败气泡重试复用同一键(对齐 P2a 幂等)。 +- [x] ACP session list/detail 显示和「可观察不可发送」状态(composer 分层阻断 + `setActiveSession` 保留 kind/capabilities)。 +- [x] 旧 host vs 参数错误的判定代码:Rust 拆 `invalid_acp_command_params` / unknown cmd(并有防漂移契约测 + 从 serde 的 unknown-variant 错误反推 `Acp*` variant 全集);手机侧 old_host 改由 `ping` 探针判定, + 结构化错误在两条 transport 上都不再被压平。 +- [ ] 旧 Desktop 真机验收(§9.3 第一行)。判定代码已单测覆盖,但需要一个真正的 pre-ACP Desktop 构建才能收口。 +- [x] ACP event projection、poll/backfill、active turn 和 terminal states(§9.2 核心)。 +- [x] ACP plan/commands/options UI;无对应 metadata 时保持为空,不回退到 native 控件。 +- [x] ACP permission UI、`permission_id + option_id` 响应路由与重连状态恢复的契约 / HarmonyOS 测试。 +- [ ] ACP permission 真机端到端验收:当前 dsh 流程直接执行测试命令,没有产生 provider permission request。 +- [x] 网络断开与 Desktop 重启真机验收。 +- [ ] peer host 真机验收:当前没有可用 peer 环境。 + +P3 验证约束:`scripts/ohos-env.sh` 存在,LocalTest 可跑,但**必须走 +`src/apps/mobile/harmonyos/scripts/run-local-tests.sh`**:hvigor 在 spec 失败时仍然 `BUILD SUCCESSFUL` +并退出 0(hypium 只打一行 `hvigor ERROR: Error in , ...`),直接跑裸命令会把红的套件读成绿的。 +判定逻辑仍尽量下沉到 Rust;`.ets` 侧策略集中在 `RemoteSessionKindPolicy.ets` 一类纯策略文件,并配 +`TransportAndGeneralChatUnit.test.ets` 风格单测,避免正确性只能靠 DevEco 手测兜底。 + +### P3 当前验收记录(2026-08-24) + +- 物理 HarmonyOS 设备、1080 × 2444、compact + dark:ACP 会话识别、控制面板、session option、发送、流式回复、 + 工具状态和 terminal 刷新通过。 +- 单写入边界:一次手机发送只新增一个 durable turn。Web UI 对 ACP turn 不再调用 native transcript persistence; + `AcpDurableProjectionWriter` 是唯一 writer。已存在的重复历史不自动删除,避免用破坏性清理代替兼容处理。 +- 取消:手机发送 `acp_cancel_turn` 后,持久 turn 为 `cancelled`,运行中的 `sleep 30` 被中止;重复 + `ModelRoundStarted` 按 round id 幂等合并,不再留下 running 工具残影。 +- 断线 / 重启:Desktop 停止时手机显示目标不可用和恢复连接状态;Desktop 重启后自动恢复同一 ACP 会话、历史和输入能力。 + 零 cursor 且控制端已有缓存时,host 会发送权威 `message_snapshot`;老客户端继续消费 additive `new_messages`。 + 手机重连时同时重置 ACP metadata 的进程内版本游标并重新 hydrate,避免新 host 从版本 1 计数时被旧游标挡住。 +- 仍待真机:pre-ACP Desktop、provider permission request、wide + light、peer host。Remote workspace ACP 未在本轮设备验收中执行; + Detached Dispatch 仍按 §10 明确不在本设计范围内。 + +## 12. 验收与测试 + +### 12.1 Rust contract tests + +- `AcpClientStreamEvent -> AgenticEvent` 对每个 variant 的字段、origin、round identity 映射。 +- ACP stream 顺序:start -> round -> chunks/tools -> round complete -> terminal;terminal 不重复。 +- origin 传递:`ExternalAcp` envelope 经 `route` 后,覆写了 `on_envelope` 的订阅者读到 `ExternalAcp`,未覆写的订阅者 + 仍按 `on_event` 正常工作;不带 origin 的历史 envelope 反序列化为 `NativeRuntime`。 +- Critical 终态 fence:同 turn 已入堆若干 `Normal` text/tool 后发布 `DialogTurnCancelled`/`DialogTurnFailed`,投递顺序 + 中 terminal 仍在这些事件之后。 +- publisher 的 bounded channel 在高频 text 下保持顺序,queue 满时 control event 不丢。 +- ACP `TextChunk` 经 `TextChunkCoalescer` 正常合批(同一 round 的 chunk 落在同一 key,不因 attempt 字段为空而分裂)。 +- `frontend_projection` 对 ACP 与 native 生成同名 canonical event;origin 不泄漏到 UI payload。 +- `SessionEventJournal` 为**符合 journal 条件的** ACP text/tool/lifecycle event 分配 cursor 并能 backfill;同时显式 + 验证 `SessionCreated` 与 `ToolEventData::StreamChunk` **不**分配 cursor(与 5.1 一致)。 +- RemoteSessionStateTracker 版本、active turn、tool、terminal 状态由 ACP event 正确推进。 +- Cron subscriber 对 ExternalAcp event 无状态变化。 +- ACP event 不合成错误的 native token usage、model selection 或 scheduler ownership。 + +### 12.2 Remote contract tests + +- 老 host 收到新 ACP command 返回 unsupported;新 host 收到旧 native command 对 ACP session fail-loud。 +- session metadata 缺新字段的旧记录可读取,且不误判为 native。 +- ACP permission response 重试、过期、重复提交和 Desktop 重启后的 mailbox 行为。 +- poll 从任意 `since_version` 恢复,不重复消息;snapshot-required 能触发完整刷新。 +- ACP session 的 tracker version 在 turn 后大于 0;在 projection 已持久化清理、model catalog 未变的前提下,重复 poll + 命中无变化短路。活动中 `persistence_dirty` 为真时仍走持久化路径,属预期行为,不作为回归。 + +### 12.3 HarmonyOS tests + +- `acp_send_message` 命令体含非空 `request_id`;发送意图层 mint 后写入乐观消息;气泡重试复用同一 id;工厂不写入 `_request_id`。 +- ACP 无 `acp_remote_control` 时 `isObservableNotSendable`;composer `allowsSend=false`;列表/详情展示 kind。 +- 分类:`invalid_acp_command_params` → param;`acp_command_error`+unsupported → control_required; + 新 host 未知 acp 名 → unsupported_command。**没有任何 wire shape 映射到 old_host**:旧 host 连 + envelope 都解析不出来,`remote_connect/mod.rs` 只 debug log 后落到 pairing 分支(无 `else`), + 对端收到的是沉默。old_host 由 `classifyAcpTransportSilence(hostAlive, cmd)` 判定——`acp_*` 超时后补一次 + `ping`(payload-free,早于 ACP 存在,任何版本都答),ping 通=host 活着却吞了 ACP 命令=版本过旧; + ping 也不通=链路断,按网络错误报,不得报成版本问题。 +- 结构化错误必须活着穿过 transport:`RelayHttpClient` 的 catch 不得把 `RemoteCommandError` 压平成裸 + `Error`,`CloudAccountClient.deviceRpc` 对 `error` 与 `acp_command_error` 都要抛结构化错误 + (否则 peer device 上 ACP 控制失败会被当成发送成功)。 +- ACP session 不显示 native composer/model/permission controls(剩余:projection 后收紧 model/permission)。 +- unsupported capability 有明确状态,不发送隐式 native command。 +- text/tool/plan/permission 在断线重连后不重复、不丢 terminal。 +- Desktop 关闭再启动后,session list、history snapshot、active turn 状态可恢复。 + +### 12.4 本地验证命令 + +实现 Rust 代码后按最近模块指南执行: + +```bash +pnpm run fmt:rs +cargo check -p bitfun-desktop +cargo test -p bitfun-services-integrations --test remote_connect_contracts --features remote-connect +cargo test -p bitfun-services-integrations --lib remote_connect::bot::acp_bot_policy --features remote-connect +cargo test -p bitfun-core --features remote-connect --lib service::remote_connect::bot::command_router::acp_wiring_tests +cargo test -p bitfun-desktop --lib runtime::acp_request_idempotency +cargo test -p bitfun-desktop --lib runtime::acp_projection_writer +cargo test -p bitfun-desktop --lib api::event_coalescer +cargo test -p bitfun-acp --lib +git diff --check +``` + +`bitfun-events` 目前没有独立 `[[test]]` target(契约用例在 `frontend_projection` 等 lib 单元测试里);不要把它当作一条空转的绿命令。P1 origin / `ModelRoundIdentity` 变更已由 desktop / acp / remote contracts 覆盖。 + +HarmonyOS 改动还应执行 `src/apps/mobile/harmonyos/AGENTS.md` 指定的 focused test。文档本身至少执行 +`git diff --check`;不要用 workspace-wide `product-full` 或 `all-features` 代替 owner-level verification。 + +## 13. 不可违反的设计检查线 + +- 看到 ACP `AppHandle::emit` 新增:说明没有走统一 delivery pipeline。 +- 看到 `AcpClientService` 直接依赖 Tauri/Core global:说明协议层越界。 +- 看到 ACP session 被 native scheduler/SessionManager admission:说明误做了 C。 +- 看到 `origin` 被加进 `AgenticEvent` 的某个 variant 而不是 envelope:说明 5.3 的管道改造被绕过了。 +- 看到 ACP 往 `model_config_id` 填 adapter 名或空串,或看到这两个字段被改成 `Option` 而不是 5.5 的枚举:说明非法状态 + 又变得可表示了。 +- 看到把 `enqueue_with_legacy_dequeue_ack` 的 ack 描述成所有订阅者的通用顺序保证:说明混淆了 dequeue 与 broadcast 两条 + 喂法(2.5)。 +- 看到 ACP 发布 `TokenUsageUpdated`:说明 `TokenUsageSubscriber`、`SessionContextUsageSubscriber` 和 + `ThreadGoalTokenSubscriber` 同时被污染,thread goal 计费会算上外部 agent 的用量。 +- 看到 remote ACP 失败回退到 `agentic`:说明升级兼容被破坏。 +- 看到手机直接获得 ACP process handle、ACP permission ID 以外的 native tool ID:说明权限边界被破坏。 +- 看到为 mobile 新写一套 tracker、transcript store 或 retry state machine:说明没有复用现有 remote owner。 + +## 14. 参考代码与文档 + +- `src/apps/desktop/src/lib.rs`:native queue consumer、coalescer、journal、frontend delivery 和 peer fanout。 +- `src/apps/desktop/src/api/acp_client_api.rs`:当前 ACP 手写 emit 路径,P1 的主要拆除点。 +- `src/crates/interfaces/acp/src/client/manager.rs`:ACP client lifecycle、stream callback、permission mailbox。 +- `src/crates/interfaces/acp/src/client/stream.rs`:ACP protocol 到 `AcpClientStreamEvent` 的解码和 round tracker。 +- `src/crates/contracts/events/src/agentic.rs`:shared `AgenticEvent` contract。 +- `src/crates/contracts/events/src/frontend_projection.rs`:canonical frontend projection。 +- `src/crates/execution/agent-runtime/src/event_queue.rs`:queue enqueue、broadcast、legacy delivery semantics; + `enqueue_with_legacy_dequeue_ack` 的 ordering fence 契约。 +- `src/crates/execution/agent-runtime/src/event_router.rs`:`EventSubscriber` trait 与 envelope 拆解点。 +- `src/crates/execution/agent-runtime/src/session_event_journal.rs`:cursor 分配条件与 backfill 覆盖范围。 +- `src/apps/desktop/src/api/event_coalescer.rs`:文本合批 key 与 flush 规则。 +- `src/crates/assembly/core/src/service/cron/subscriber.rs`:覆写 `on_envelope`,只处理 `NativeRuntime`。 +- `src/apps/desktop/src/runtime/acp_projection_writer.rs`:覆写 `on_envelope`,只处理 `ExternalAcp`;`on_event` 为空 no-op。 +- `src/crates/assembly/core/src/service_agent_runtime.rs`:RemoteSessionStateTracker subscriber。 +- `src/crates/services/services-integrations/src/remote_connect.rs`:RemoteCommand、tracker、poll 和 interaction routing。 +- `src/crates/assembly/core/src/product_runtime.rs`:externally projected session boundary。 +- `src/apps/mobile/harmonyos/docs/mobile-detached-dispatch-design.md`:HarmonyOS remote capability negotiation 和 fail-loud 约束示例。 +- `docs/architecture/product-architecture.md`:产品 assembly、adapter、runtime 和 delivery 边界。 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 0628f6aa7b..4d1db60ac4 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -31,6 +31,9 @@ Cargo feature、第三方依赖 owner、测试目标和本地/CI 验证分工见 本文件只约束稳定边界,不记录单次 PR 进度,也不把未来可能支持的生态能力提前声明为公开接口。 +ACP Client 与 Runtime 事件投递、远程控制及 HarmonyOS 演进的分阶段设计见 +[ACP Client、Runtime 事件总线与移动端支持设计](acp-client-runtime-event-integration-design.md)。 + ## 1. 架构目标 BitFun 同时面向桌面 GUI、TUI/CLI、Web、ACP、Server、Remote、SDK 和插件生态。架构目标是降低后端实现高频变更对稳定接口的影响,同时保持插件生态和 OpenCode-compatible 能力可以按受控路径扩展。 diff --git a/src/apps/cli/src/account.rs b/src/apps/cli/src/account.rs index 00cc4f8217..26f706ba7b 100644 --- a/src/apps/cli/src/account.rs +++ b/src/apps/cli/src/account.rs @@ -503,6 +503,7 @@ impl CliAccountRoutingHost { let response_json = serde_json::to_string(&response).unwrap_or_else(|error| { serde_json::to_string(&RemoteResponse::Error { message: format!("failed to serialize RPC response: {error}"), + code: None, }) .unwrap_or_else(|_| { r#"{"resp":"error","message":"serialize failed"}"#.to_string() diff --git a/src/apps/cli/src/modes/exec/lifecycle.rs b/src/apps/cli/src/modes/exec/lifecycle.rs index 25370ebc8a..960f4325e5 100644 --- a/src/apps/cli/src/modes/exec/lifecycle.rs +++ b/src/apps/cli/src/modes/exec/lifecycle.rs @@ -1289,10 +1289,16 @@ impl ExecMode { match event { AgenticEvent::ModelRoundStarted { turn_id: event_turn_id, - model_config_id, + identity: + bitfun_events::ModelRoundIdentity::Native { + model_config_id, .. + }, .. + } if event_turn_id == turn_id => { + self.record_resolved_model_config_id(session_id, model_config_id) + .await; } - | AgenticEvent::ModelRoundCompleted { + AgenticEvent::ModelRoundCompleted { turn_id: event_turn_id, model_config_id, .. diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index 83d685add8..2e873cf1bf 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -2,16 +2,16 @@ use crate::api::app_state::AppState; use crate::api::session_storage_path::desktop_effective_session_storage_path; +use crate::runtime::{acp_dialog_turn_started_event, acp_session_created_event, AcpTurnMapper}; use crate::startup_trace::DesktopStartupTrace; use bitfun_acp::client::{ AcpAvailableCommand, AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe, - AcpClientStreamEvent, AcpSessionOptions, CreateAcpFlowSessionRecordResponse, - SetAcpSessionConfigOptionRequest, SetAcpSessionModelRequest, - SubmitAcpPermissionResponseRequest, + AcpSessionOptions, CreateAcpFlowSessionRecordResponse, SetAcpSessionConfigOptionRequest, + SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; use serde::{Deserialize, Serialize}; use std::time::Instant; -use tauri::{AppHandle, Emitter, State}; +use tauri::State; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -90,26 +90,6 @@ pub struct ProbeAcpClientRequirementsRequest { pub force_refresh: bool, } -fn emit_acp_model_round_completed( - app_handle: &AppHandle, - session_id: &str, - turn_id: &str, - round_id: String, - has_tool_calls: bool, -) -> Result<(), bitfun_core::util::errors::BitFunError> { - app_handle - .emit( - "agentic://model-round-completed", - serde_json::json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "hasToolCalls": has_tool_calls, - }), - ) - .map_err(|e| bitfun_core::util::errors::BitFunError::service(e.to_string())) -} - #[tauri::command] pub async fn initialize_acp_clients( state: State<'_, AppState>, @@ -199,7 +179,6 @@ pub async fn install_acp_client_cli( #[tauri::command] pub async fn create_acp_flow_session( state: State<'_, AppState>, - app_handle: AppHandle, request: CreateAcpFlowSessionRequest, ) -> Result { let service = state @@ -245,17 +224,16 @@ pub async fn create_acp_flow_session( return Err(error.to_string()); } - let _ = app_handle.emit( - "agentic://session-created", - serde_json::json!({ - "sessionId": response.session_id.clone(), - "sessionName": response.session_name.clone(), - "agentType": response.agent_type.clone(), - "workspacePath": request.workspace_path, - "remoteConnectionId": request.remote_connection_id, - "remoteSshHost": request.remote_ssh_host, - }), - ); + state + .acp_event_publisher + .publish_session_created(acp_session_created_event( + response.session_id.clone(), + response.session_name.clone(), + response.agent_type.clone(), + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + ))?; Ok(response) } @@ -263,7 +241,6 @@ pub async fn create_acp_flow_session( #[tauri::command] pub async fn start_acp_dialog_turn( state: State<'_, AppState>, - app_handle: AppHandle, request: StartAcpDialogTurnRequest, ) -> Result<(), String> { let service = state @@ -271,6 +248,7 @@ pub async fn start_acp_dialog_turn( .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())? .clone(); + let publisher = state.acp_event_publisher.clone(); let session_id = request.session_id.clone(); let turn_id = request.turn_id.clone(); @@ -278,7 +256,7 @@ pub async fn start_acp_dialog_turn( let original_user_input = request .original_user_input .clone() - .unwrap_or_else(|| request.user_input.clone()); + .filter(|value| value != &request.user_input); let session_storage_path = match request.workspace_path.as_deref() { Some(workspace_path) => Some( desktop_effective_session_storage_path( @@ -292,23 +270,18 @@ pub async fn start_acp_dialog_turn( None => None, }; - app_handle - .emit( - "agentic://dialog-turn-started", - serde_json::json!({ - "sessionId": session_id, - "turnId": turn_id, - "turnIndex": null, - "userInput": user_input, - "originalUserInput": original_user_input, - "userMessageMetadata": null, - "subagentParentInfo": null, - }), - ) - .map_err(|e| e.to_string())?; + publisher.publish_turn_started(acp_dialog_turn_started_event( + session_id.clone(), + turn_id.clone(), + user_input, + original_user_input, + ))?; tokio::spawn(async move { - let mut current_round_id: Option = None; - let mut current_round_has_tool_calls = false; + let mut mapper = AcpTurnMapper::new( + request.session_id.clone(), + request.turn_id.clone(), + request.client_id.clone(), + ); let result = service .prompt_agent_stream( &request.client_id, @@ -319,232 +292,16 @@ pub async fn start_acp_dialog_turn( session_storage_path, request.timeout_seconds, |event| { - match event { - AcpClientStreamEvent::ModelRoundStarted { - round_id, - round_index, - disable_explore_grouping, - } => { - if let Some(previous_round_id) = current_round_id.take() { - emit_acp_model_round_completed( - &app_handle, - &request.session_id, - &request.turn_id, - previous_round_id, - current_round_has_tool_calls, - )?; - } - current_round_id = Some(round_id.clone()); - current_round_has_tool_calls = false; - app_handle - .emit( - "agentic://model-round-started", - serde_json::json!({ - "sessionId": request.session_id, - "turnId": request.turn_id, - "roundId": round_id, - "roundIndex": round_index, - "renderHints": { - "disableExploreGrouping": disable_explore_grouping, - }, - "subagentParentInfo": null, - }), - ) - .map_err(|e| { - bitfun_core::util::errors::BitFunError::service(e.to_string()) - })?; - } - AcpClientStreamEvent::AgentText(text) => { - let round_id = current_round_id.clone().ok_or_else(|| { - bitfun_core::util::errors::BitFunError::service( - "ACP text arrived before model round start".to_string(), - ) - })?; - app_handle - .emit( - "agentic://text-chunk", - serde_json::json!({ - "sessionId": request.session_id, - "turnId": request.turn_id, - "roundId": round_id, - "text": text, - "subagentParentInfo": null, - }), - ) - .map_err(|e| { - bitfun_core::util::errors::BitFunError::service(e.to_string()) - })?; - } - AcpClientStreamEvent::AgentThought(text) => { - let round_id = current_round_id.clone().ok_or_else(|| { - bitfun_core::util::errors::BitFunError::service( - "ACP thought arrived before model round start".to_string(), - ) - })?; - app_handle - .emit( - "agentic://text-chunk", - serde_json::json!({ - "sessionId": request.session_id, - "turnId": request.turn_id, - "roundId": round_id, - "text": text, - "contentType": "thinking", - "isThinkingEnd": false, - "subagentParentInfo": null, - }), - ) - .map_err(|e| { - bitfun_core::util::errors::BitFunError::service(e.to_string()) - })?; - } - AcpClientStreamEvent::ToolEvent(tool_event) => { - let round_id = current_round_id.clone().ok_or_else(|| { - bitfun_core::util::errors::BitFunError::service( - "ACP tool event arrived before model round start".to_string(), - ) - })?; - current_round_has_tool_calls = true; - app_handle - .emit( - "agentic://tool-event", - serde_json::json!({ - "sessionId": request.session_id, - "turnId": request.turn_id, - "roundId": round_id, - "toolEvent": tool_event, - "subagentParentInfo": null, - }), - ) - .map_err(|e| { - bitfun_core::util::errors::BitFunError::service(e.to_string()) - })?; - } - AcpClientStreamEvent::ContextUsageUpdated(usage) => { - app_handle - .emit( - "agentic://acp-context-usage-updated", - serde_json::json!({ - "sessionId": request.session_id, - "turnId": request.turn_id, - "clientId": request.client_id, - "used": usage.used, - "size": usage.size, - "cost": usage.cost, - "subagentParentInfo": null, - }), - ) - .map_err(|e| { - bitfun_core::util::errors::BitFunError::service(e.to_string()) - })?; - } - AcpClientStreamEvent::AvailableCommandsUpdated(commands) => { - app_handle - .emit( - "agentic://acp-available-commands-updated", - serde_json::json!({ - "sessionId": request.session_id, - "clientId": request.client_id, - "commands": commands, - }), - ) - .map_err(|e| { - bitfun_core::util::errors::BitFunError::service(e.to_string()) - })?; - } - AcpClientStreamEvent::PlanUpdated(entries) => { - app_handle - .emit( - "agentic://acp-plan-updated", - serde_json::json!({ - "sessionId": request.session_id, - "turnId": request.turn_id, - "clientId": request.client_id, - "entries": entries, - }), - ) - .map_err(|e| { - bitfun_core::util::errors::BitFunError::service(e.to_string()) - })?; - } - AcpClientStreamEvent::ConfigOptionsUpdated(_) => { - app_handle - .emit( - "agentic://acp-session-options-changed", - serde_json::json!({ - "sessionId": request.session_id, - "clientId": request.client_id, - }), - ) - .map_err(|e| { - bitfun_core::util::errors::BitFunError::service(e.to_string()) - })?; - } - AcpClientStreamEvent::Completed => { - if let Some(round_id) = current_round_id.take() { - emit_acp_model_round_completed( - &app_handle, - &request.session_id, - &request.turn_id, - round_id, - current_round_has_tool_calls, - )?; - } - app_handle - .emit( - "agentic://dialog-turn-completed", - serde_json::json!({ - "sessionId": request.session_id, - "turnId": request.turn_id, - "subagentParentInfo": null, - "partialRecoveryReason": null, - }), - ) - .map_err(|e| { - bitfun_core::util::errors::BitFunError::service(e.to_string()) - })?; - } - AcpClientStreamEvent::Cancelled => { - if let Some(round_id) = current_round_id.take() { - emit_acp_model_round_completed( - &app_handle, - &request.session_id, - &request.turn_id, - round_id, - current_round_has_tool_calls, - )?; - } - app_handle - .emit( - "agentic://dialog-turn-cancelled", - serde_json::json!({ - "sessionId": request.session_id, - "turnId": request.turn_id, - "subagentParentInfo": null, - }), - ) - .map_err(|e| { - bitfun_core::util::errors::BitFunError::service(e.to_string()) - })?; - } - } - Ok(()) + let jobs = mapper.map(event)?; + publisher + .publish_jobs(jobs) + .map_err(bitfun_core::util::errors::BitFunError::service) }, ) .await; if let Err(error) = result { - let _ = app_handle.emit( - "agentic://dialog-turn-failed", - serde_json::json!({ - "sessionId": request.session_id, - "turnId": request.turn_id, - "error": error.to_string(), - "errorCategory": null, - "errorDetail": null, - "subagentParentInfo": null, - }), - ); + let _ = publisher.publish_jobs(mapper.fail(error.to_string())); } }); @@ -633,6 +390,7 @@ pub async fn get_acp_session_commands( request.session_id, ) .await + .map(|(commands, _version)| commands) .map_err(|e| e.to_string()) } @@ -743,3 +501,43 @@ pub async fn submit_acp_permission_response( .await .map_err(|e| e.to_string()) } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListAcpPendingPermissionsRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpPendingPermissionEntry { + pub permission_id: String, + pub session_id: String, + pub tool_call: serde_json::Value, + pub options: serde_json::Value, + pub created_at_ms: u64, + pub expires_at_ms: u64, +} + +/// Rehydrate local Web UI from the shared ACP permission mailbox after refresh. +#[tauri::command] +pub async fn list_acp_pending_permissions( + request: ListAcpPendingPermissionsRequest, +) -> Result, String> { + let Some(mailbox) = bitfun_services_integrations::remote_connect::acp_permission_mailbox() + else { + return Ok(Vec::new()); + }; + Ok(mailbox + .list_for_session(&request.session_id) + .into_iter() + .map(|entry| AcpPendingPermissionEntry { + permission_id: entry.permission_id, + session_id: entry.session_id, + tool_call: entry.tool_call, + options: entry.options, + created_at_ms: entry.created_at_ms, + expires_at_ms: entry.expires_at_ms, + }) + .collect()) +} diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index c4b382ce7c..886c3d81f9 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -77,6 +77,7 @@ pub struct AppState { pub agent_registry: Arc, pub mcp_service: Option>, pub acp_client_service: Option>, + pub acp_event_publisher: Arc, pub token_usage_service: Arc, pub miniapp_manager: Arc, pub js_worker_pool: Option>, @@ -101,6 +102,7 @@ pub struct AppState { impl AppState { pub async fn new_async( token_usage_service: Arc, + acp_event_publisher: Arc, ) -> BitFunResult { let start_time = std::time::Instant::now(); @@ -344,6 +346,7 @@ impl AppState { agent_registry, mcp_service, acp_client_service, + acp_event_publisher, token_usage_service, miniapp_manager, js_worker_pool, diff --git a/src/apps/desktop/src/api/event_coalescer.rs b/src/apps/desktop/src/api/event_coalescer.rs index 53c35867e7..f807a3bac7 100644 --- a/src/apps/desktop/src/api/event_coalescer.rs +++ b/src/apps/desktop/src/api/event_coalescer.rs @@ -766,6 +766,67 @@ mod tests { assert_eq!(coalescer.buffered_chars(), 0); } + #[test] + fn acp_shaped_text_chunks_with_empty_attempt_fields_share_one_key() { + // ACP stream mapping leaves attempt_id/attempt_index as None. The + // coalescer must normalize both to the same "none" token so one round + // stays one stream. If this fallback ever becomes a uuid or round_id, + // ACP streaming text silently fragments into one event per chunk. + let mut coalescer = TextChunkCoalescer::new(); + assert_eq!(resolve_attempt_token(&None, None), "none"); + assert!(coalescer + .push(text_chunk( + "acp-session", + "turn-1", + "round-1", + None, + None, + "Hel" + )) + .is_empty()); + assert!(coalescer + .push(text_chunk( + "acp-session", + "turn-1", + "round-1", + None, + None, + "lo " + )) + .is_empty()); + assert!(coalescer + .push(text_chunk( + "acp-session", + "turn-1", + "round-1", + None, + None, + "ACP" + )) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 1); + match &events[0] { + AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + text, + } => { + assert_eq!(session_id, "acp-session"); + assert_eq!(turn_id, "turn-1"); + assert_eq!(round_id, "round-1"); + assert!(attempt_id.is_none()); + assert!(attempt_index.is_none()); + assert_eq!(text, "Hello ACP"); + } + other => panic!("expected merged ACP TextChunk, got {other:?}"), + } + } + #[test] fn rate_ema_resets_after_long_idle_gap() { // A window longer than the reset threshold must replace the estimate diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index b4d8503477..3a46ad09a8 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -894,6 +894,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("install_update", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "list_acp_pending_permissions", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "list_agent_companion_pets", RemoteWorkspacePolicy::LegacyUnaudited, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 49764a16eb..8509ed6dfc 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -724,13 +724,24 @@ pub async fn run() { ); let step_started = Instant::now(); - let app_state = match AppState::new_async(token_usage_service).await { + let acp_event_publisher = crate::runtime::AcpEventPublisher::start(event_queue.clone()); + let app_state = match AppState::new_async(token_usage_service, acp_event_publisher).await { Ok(state) => state, Err(e) => { log::error!("Failed to initialize AppState: {}", e); return; } }; + if let Some(service) = app_state.acp_client_service.clone() { + let mailbox = bitfun_services_integrations::remote_connect::install_acp_permission_mailbox( + std::sync::Arc::new( + bitfun_services_integrations::remote_connect::AcpPermissionMailbox::default(), + ), + ); + service.set_permission_observer(std::sync::Arc::new( + runtime::DesktopAcpPermissionObserver::new(mailbox), + )); + } startup_timings.record_elapsed("initialize_app_state", step_started); startup_trace.record_elapsed_step("native_pre_tauri", "initialize_app_state", step_started); @@ -769,6 +780,24 @@ pub async fn run() { "initialize_desktop_agent_runtime", step_started, ); + let acp_projection_writer = + runtime::install_desktop_acp_writer(runtime::AcpDurableProjectionWriter::new( + event_queue.clone(), + desktop_runtime.session_application().clone(), + )); + event_router.subscribe_internal( + "acp_durable_projection".to_string(), + acp_projection_writer.clone(), + ); + if let Some(service) = app_state.acp_client_service.clone() { + bitfun_core::service::remote_connect::set_remote_acp_control_host(std::sync::Arc::new( + runtime::DesktopRemoteAcpControlHost::new( + service, + app_state.acp_event_publisher.clone(), + acp_projection_writer, + ), + )); + } let coordinator_state = CoordinatorState { coordinator: coordinator.clone(), @@ -1682,6 +1711,7 @@ pub async fn run() { load_acp_json_config, save_acp_json_config, submit_acp_permission_response, + list_acp_pending_permissions, create_acp_flow_session, start_acp_dialog_turn, cancel_acp_dialog_turn, @@ -2307,6 +2337,7 @@ pub(crate) fn perform_process_exit_cleanup() -> bool { if let Some(search_service) = get_global_workspace_search_service() { search_service.shutdown_blocking(); } + runtime::flush_desktop_acp_writer_blocking(); bitfun_core::util::process_manager::cleanup_all_processes(); api::remote_connect_api::cleanup_on_exit(); true @@ -2547,6 +2578,8 @@ fn start_event_loop_with_transport( session_event_journal: Arc, ) { tokio::spawn(async move { + // Live WebView delivery stays origin-unaware. Origin is a subscriber + // routing fact on the envelope; journal/backfill do not persist it yet. event_loop_driver(event_queue, event_router, |event| { let transport = transport.clone(); let session_event_journal = session_event_journal.clone(); diff --git a/src/apps/desktop/src/runtime/acp_event_publisher.rs b/src/apps/desktop/src/runtime/acp_event_publisher.rs new file mode 100644 index 0000000000..5e9397179b --- /dev/null +++ b/src/apps/desktop/src/runtime/acp_event_publisher.rs @@ -0,0 +1,654 @@ +//! Desktop ACP observation publisher. +//! +//! Maps ACP protocol stream events to `AgenticEvent` and enqueues them onto +//! the existing Desktop `EventQueue` with `ExternalAcp` origin. ACP protocol +//! execution stays in `bitfun-acp`; this type only owns ordered observation. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Instant; + +use bitfun_acp::client::{ + AcpAvailableCommand, AcpClientStreamEvent, AcpPlanEntry, AcpSessionContextUsage, +}; +use bitfun_core::agentic::events::EventQueue; +use bitfun_core::util::errors::BitFunError; +use bitfun_events::{ + AcpAvailableCommandFact, AcpPlanEntryFact, AgenticEvent, AgenticEventOrigin, + AgenticEventPriority, ModelRoundIdentity, ModelRoundRenderHints, +}; +use log::warn; + +/// Soft in-flight watermark for the unbounded publisher channel. +/// Best-effort stream chunks are dropped above this; control events are not. +const ACP_EVENT_CHANNEL_WATERMARK: usize = 2048; + +#[derive(Debug, Clone)] +pub(crate) enum AcpPublishJob { + BestEffort(AgenticEvent), + Guaranteed(AgenticEvent), + Fence(AgenticEvent), +} + +#[derive(Clone)] +pub struct AcpEventPublisher { + tx: tokio::sync::mpsc::UnboundedSender, + /// In-flight depth of this publisher channel (jobs sent, not yet `recv`'d). + /// It is not the EventQueue depth. Best-effort drops compare against this. + queued: Arc, + watermark: usize, +} + +impl AcpEventPublisher { + pub(crate) fn start(queue: Arc) -> Arc { + Self::start_with_watermark(queue, ACP_EVENT_CHANNEL_WATERMARK) + } + + fn start_with_watermark(queue: Arc, watermark: usize) -> Arc { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let queued = Arc::new(AtomicUsize::new(0)); + let publisher = Arc::new(Self { + tx, + queued: queued.clone(), + watermark, + }); + tokio::spawn(run_publisher_worker(queue, rx, queued)); + publisher + } + + #[cfg(test)] + fn start_paused( + watermark: usize, + ) -> ( + Arc, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + ( + Arc::new(Self { + tx, + queued: Arc::new(AtomicUsize::new(0)), + watermark, + }), + rx, + ) + } + + pub(crate) fn publish_session_created(&self, event: AgenticEvent) -> Result<(), String> { + self.send(AcpPublishJob::Guaranteed(event)) + } + + pub(crate) fn publish_turn_started(&self, event: AgenticEvent) -> Result<(), String> { + self.send(AcpPublishJob::Guaranteed(event)) + } + + pub(crate) fn publish_jobs(&self, jobs: Vec) -> Result<(), String> { + for job in jobs { + self.send(job)?; + } + Ok(()) + } + + fn send(&self, job: AcpPublishJob) -> Result<(), String> { + match &job { + AcpPublishJob::BestEffort(_) => { + if !self.try_reserve_best_effort() { + warn!("ACP stream channel is above watermark; dropping best-effort event"); + return Ok(()); + } + } + AcpPublishJob::Guaranteed(_) | AcpPublishJob::Fence(_) => { + self.queued.fetch_add(1, Ordering::Relaxed); + } + } + + if let Err(error) = self.tx.send(job) { + self.queued.fetch_sub(1, Ordering::Relaxed); + return Err(format!("ACP event channel closed: {error}")); + } + Ok(()) + } + + fn try_reserve_best_effort(&self) -> bool { + let mut current = self.queued.load(Ordering::Relaxed); + loop { + if current >= self.watermark { + return false; + } + match self.queued.compare_exchange_weak( + current, + current + 1, + Ordering::AcqRel, + Ordering::Relaxed, + ) { + Ok(_) => return true, + Err(next) => current = next, + } + } + } +} + +async fn run_publisher_worker( + queue: Arc, + mut rx: tokio::sync::mpsc::UnboundedReceiver, + queued: Arc, +) { + while let Some(job) = rx.recv().await { + queued.fetch_sub(1, Ordering::Relaxed); + if let Err(error) = publish_job(&queue, job).await { + warn!("ACP event enqueue failed: {error}"); + } + } +} + +async fn publish_job(queue: &EventQueue, job: AcpPublishJob) -> Result<(), String> { + let origin = AgenticEventOrigin::ExternalAcp; + match job { + AcpPublishJob::BestEffort(event) => queue + .enqueue_with_origin(event, None, origin) + .await + .map(|_| ()) + .map_err(|error| error.to_string()), + AcpPublishJob::Guaranteed(event) => queue + .enqueue_with_guaranteed_legacy_storage_with_origin(event, None, origin) + .await + .map(|_| ()) + .map_err(|error| error.to_string()), + AcpPublishJob::Fence(event) => { + let (_, ack) = queue + .enqueue_with_legacy_dequeue_ack_with_origin( + event, + Some(AgenticEventPriority::Normal), + origin, + ) + .await + .map_err(|error| error.to_string())?; + ack.wait() + .await + .map_err(|error| format!("ACP terminal fence ack failed: {error}")) + } + } +} + +pub(crate) struct AcpTurnMapper { + session_id: String, + turn_id: String, + client_id: String, + current_round_id: Option, + current_round_has_tool_calls: bool, + closed_rounds: usize, + total_tools: usize, + started_at: Instant, + terminal_emitted: bool, +} + +impl AcpTurnMapper { + pub(crate) fn new(session_id: String, turn_id: String, client_id: String) -> Self { + Self { + session_id, + turn_id, + client_id, + current_round_id: None, + current_round_has_tool_calls: false, + closed_rounds: 0, + total_tools: 0, + started_at: Instant::now(), + terminal_emitted: false, + } + } + + pub(crate) fn map( + &mut self, + event: AcpClientStreamEvent, + ) -> Result, BitFunError> { + match event { + AcpClientStreamEvent::ModelRoundStarted { + round_id, + round_index, + disable_explore_grouping, + } => { + let mut jobs = Vec::new(); + if let Some(job) = self.close_current_round() { + jobs.push(job); + } + self.current_round_id = Some(round_id.clone()); + self.current_round_has_tool_calls = false; + jobs.push(AcpPublishJob::BestEffort(AgenticEvent::ModelRoundStarted { + session_id: self.session_id.clone(), + turn_id: self.turn_id.clone(), + round_id, + round_group_id: None, + round_index, + identity: ModelRoundIdentity::External { + provider: "acp".to_string(), + client_id: self.client_id.clone(), + model_id: None, + display_name: None, + }, + render_hints: Some(ModelRoundRenderHints { + disable_explore_grouping, + }), + })); + Ok(jobs) + } + AcpClientStreamEvent::AgentText(text) => { + let round_id = self.require_round("ACP text arrived before model round start")?; + Ok(vec![AcpPublishJob::BestEffort(AgenticEvent::TextChunk { + session_id: self.session_id.clone(), + turn_id: self.turn_id.clone(), + round_id, + attempt_id: None, + attempt_index: None, + text, + })]) + } + AcpClientStreamEvent::AgentThought(text) => { + let round_id = + self.require_round("ACP thought arrived before model round start")?; + Ok(vec![AcpPublishJob::BestEffort( + AgenticEvent::ThinkingChunk { + session_id: self.session_id.clone(), + turn_id: self.turn_id.clone(), + round_id, + attempt_id: None, + attempt_index: None, + content: text, + reasoning_kind: None, + is_end: false, + }, + )]) + } + AcpClientStreamEvent::ToolEvent(tool_event) => { + let round_id = + self.require_round("ACP tool event arrived before model round start")?; + self.current_round_has_tool_calls = true; + self.total_tools += 1; + Ok(vec![AcpPublishJob::BestEffort(AgenticEvent::ToolEvent { + session_id: self.session_id.clone(), + turn_id: self.turn_id.clone(), + round_id, + attempt_id: None, + attempt_index: None, + tool_event, + })]) + } + AcpClientStreamEvent::ContextUsageUpdated(usage) => { + Ok(vec![AcpPublishJob::BestEffort(map_context_usage( + &self.session_id, + &self.turn_id, + &self.client_id, + usage, + ))]) + } + AcpClientStreamEvent::AvailableCommandsUpdated(commands) => { + Ok(vec![AcpPublishJob::BestEffort( + AgenticEvent::AcpAvailableCommandsUpdated { + session_id: self.session_id.clone(), + client_id: self.client_id.clone(), + commands: commands.into_iter().map(map_available_command).collect(), + }, + )]) + } + AcpClientStreamEvent::PlanUpdated(entries) => Ok(vec![AcpPublishJob::BestEffort( + AgenticEvent::AcpPlanUpdated { + session_id: self.session_id.clone(), + turn_id: self.turn_id.clone(), + client_id: self.client_id.clone(), + entries: entries.into_iter().map(map_plan_entry).collect(), + }, + )]), + AcpClientStreamEvent::ConfigOptionsUpdated(_) => Ok(vec![AcpPublishJob::BestEffort( + AgenticEvent::AcpSessionOptionsChanged { + session_id: self.session_id.clone(), + client_id: self.client_id.clone(), + }, + )]), + AcpClientStreamEvent::Completed => Ok(self.complete_turn()), + AcpClientStreamEvent::Cancelled => Ok(self.cancel_turn()), + } + } + + pub(crate) fn fail(&mut self, error: String) -> Vec { + if self.terminal_emitted { + return Vec::new(); + } + self.terminal_emitted = true; + let mut jobs = Vec::new(); + if let Some(job) = self.close_current_round() { + jobs.push(job); + } + jobs.push(AcpPublishJob::Fence(AgenticEvent::DialogTurnFailed { + session_id: self.session_id.clone(), + turn_id: self.turn_id.clone(), + error, + error_category: None, + error_detail: None, + })); + jobs + } + + fn complete_turn(&mut self) -> Vec { + if self.terminal_emitted { + return Vec::new(); + } + self.terminal_emitted = true; + let mut jobs = Vec::new(); + if let Some(job) = self.close_current_round() { + jobs.push(job); + } + jobs.push(AcpPublishJob::Guaranteed( + AgenticEvent::DialogTurnCompleted { + session_id: self.session_id.clone(), + turn_id: self.turn_id.clone(), + total_rounds: self.closed_rounds, + total_tools: self.total_tools, + duration_ms: elapsed_ms(self.started_at), + partial_recovery_reason: None, + success: Some(true), + finish_reason: Some("complete".to_string()), + has_final_response: None, + }, + )); + jobs + } + + fn cancel_turn(&mut self) -> Vec { + if self.terminal_emitted { + return Vec::new(); + } + self.terminal_emitted = true; + let mut jobs = Vec::new(); + if let Some(job) = self.close_current_round() { + jobs.push(job); + } + jobs.push(AcpPublishJob::Fence(AgenticEvent::DialogTurnCancelled { + session_id: self.session_id.clone(), + turn_id: self.turn_id.clone(), + })); + jobs + } + + fn close_current_round(&mut self) -> Option { + let round_id = self.current_round_id.take()?; + let has_tool_calls = self.current_round_has_tool_calls; + self.current_round_has_tool_calls = false; + self.closed_rounds += 1; + Some(AcpPublishJob::BestEffort( + AgenticEvent::ModelRoundCompleted { + session_id: self.session_id.clone(), + turn_id: self.turn_id.clone(), + round_id, + has_tool_calls, + duration_ms: None, + provider_id: None, + model_config_id: String::new(), + effective_model_name: String::new(), + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + }, + )) + } + + fn require_round(&self, message: &str) -> Result { + self.current_round_id + .clone() + .ok_or_else(|| BitFunError::service(message.to_string())) + } +} + +fn map_context_usage( + session_id: &str, + turn_id: &str, + client_id: &str, + usage: AcpSessionContextUsage, +) -> AgenticEvent { + AgenticEvent::AcpContextUsageUpdated { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + client_id: client_id.to_string(), + used: usage.used, + size: usage.size, + cost: usage.cost.and_then(|cost| serde_json::to_value(cost).ok()), + } +} + +fn map_available_command(command: AcpAvailableCommand) -> AcpAvailableCommandFact { + AcpAvailableCommandFact { + name: command.name, + description: command.description, + input_hint: command.input_hint, + } +} + +fn map_plan_entry(entry: AcpPlanEntry) -> AcpPlanEntryFact { + AcpPlanEntryFact { + content: entry.content, + priority: entry.priority, + status: entry.status, + } +} + +fn elapsed_ms(started_at: Instant) -> u64 { + started_at.elapsed().as_millis() as u64 +} + +pub(crate) fn acp_session_created_event( + session_id: String, + session_name: String, + agent_type: String, + workspace_path: String, + remote_connection_id: Option, + remote_ssh_host: Option, +) -> AgenticEvent { + AgenticEvent::SessionCreated { + session_id, + session_name, + agent_type, + workspace_path: Some(workspace_path), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id, + remote_ssh_host, + } +} + +pub(crate) fn acp_dialog_turn_started_event( + session_id: String, + turn_id: String, + user_input: String, + original_user_input: Option, +) -> AgenticEvent { + AgenticEvent::DialogTurnStarted { + session_id, + turn_id, + turn_index: 0, + user_input, + original_user_input, + user_message_metadata: None, + } +} + +#[cfg(test)] +mod tests { + use super::{AcpPublishJob, AcpTurnMapper}; + use bitfun_acp::client::AcpClientStreamEvent; + use bitfun_events::{AgenticEvent, ModelRoundIdentity}; + + #[test] + fn maps_round_text_and_complete_without_token_usage() { + let mut mapper = AcpTurnMapper::new( + "session-1".to_string(), + "turn-1".to_string(), + "gemini".to_string(), + ); + let started = mapper + .map(AcpClientStreamEvent::ModelRoundStarted { + round_id: "round-1".to_string(), + round_index: 0, + disable_explore_grouping: true, + }) + .expect("map start"); + assert!(matches!( + started[0], + AcpPublishJob::BestEffort(AgenticEvent::ModelRoundStarted { + identity: ModelRoundIdentity::External { .. }, + .. + }) + )); + let text = mapper + .map(AcpClientStreamEvent::AgentText("hello".to_string())) + .expect("map text"); + assert!(matches!( + text[0], + AcpPublishJob::BestEffort(AgenticEvent::TextChunk { .. }) + )); + let completed = mapper + .map(AcpClientStreamEvent::Completed) + .expect("map complete"); + assert!(completed.iter().any(|job| matches!( + job, + AcpPublishJob::Guaranteed(AgenticEvent::DialogTurnCompleted { .. }) + ))); + assert!(completed.iter().all(|job| { + !matches!( + job, + AcpPublishJob::BestEffort(AgenticEvent::TokenUsageUpdated { .. }) + ) + })); + assert!(mapper.fail("late".to_string()).is_empty()); + } + + #[test] + fn text_before_round_fails_loud() { + let mut mapper = AcpTurnMapper::new( + "session-1".to_string(), + "turn-1".to_string(), + "gemini".to_string(), + ); + let error = mapper + .map(AcpClientStreamEvent::AgentText("early".to_string())) + .expect_err("must fail"); + assert!(error.to_string().contains("before model round start")); + } + + #[tokio::test] + async fn publish_turn_started_from_async_context_reaches_queue() { + use super::{acp_dialog_turn_started_event, AcpEventPublisher}; + use bitfun_core::agentic::events::EventQueue; + use bitfun_events::AgenticEventOrigin; + use std::sync::Arc; + use std::time::Duration; + + let queue = Arc::new(EventQueue::new(Default::default())); + let publisher = AcpEventPublisher::start(queue.clone()); + publisher + .publish_turn_started(acp_dialog_turn_started_event( + "session-1".to_string(), + "turn-1".to_string(), + "hello".to_string(), + None, + )) + .expect("publish_turn_started must not panic or fail inside a Tokio runtime"); + + let batch = wait_for_queue_event(&queue).await; + assert_eq!(batch.len(), 1); + assert_eq!(batch[0].origin, AgenticEventOrigin::ExternalAcp); + assert!(matches!( + batch[0].event, + AgenticEvent::DialogTurnStarted { + ref session_id, + ref turn_id, + .. + } if session_id == "session-1" && turn_id == "turn-1" + )); + + async fn wait_for_queue_event( + queue: &EventQueue, + ) -> Vec { + for _ in 0..50 { + let batch = queue.dequeue_configured_batch().await; + if !batch.is_empty() { + return batch; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("publisher worker did not enqueue DialogTurnStarted"); + } + } + + #[test] + fn watermark_drops_only_best_effort_and_keeps_channel_order() { + use super::AcpEventPublisher; + + let (publisher, mut rx) = AcpEventPublisher::start_paused(1); + publisher + .publish_jobs(vec![ + AcpPublishJob::BestEffort(text_chunk("kept")), + AcpPublishJob::BestEffort(text_chunk("dropped")), + AcpPublishJob::Guaranteed(dialog_turn_completed()), + AcpPublishJob::Fence(dialog_turn_failed()), + ]) + .expect("control events must never fail at the watermark"); + + let first = rx.try_recv().expect("accepted best-effort event"); + let second = rx.try_recv().expect("guaranteed event"); + let third = rx.try_recv().expect("fence event"); + assert!( + rx.try_recv().is_err(), + "overflow best-effort events must be dropped, not reordered" + ); + assert!(matches!( + first, + AcpPublishJob::BestEffort(AgenticEvent::TextChunk { ref text, .. }) + if text == "kept" + )); + assert!(matches!( + second, + AcpPublishJob::Guaranteed(AgenticEvent::DialogTurnCompleted { .. }) + )); + assert!(matches!( + third, + AcpPublishJob::Fence(AgenticEvent::DialogTurnFailed { .. }) + )); + } + + fn text_chunk(text: &str) -> AgenticEvent { + AgenticEvent::TextChunk { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: text.to_string(), + } + } + + fn dialog_turn_completed() -> AgenticEvent { + AgenticEvent::DialogTurnCompleted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + total_rounds: 1, + total_tools: 0, + duration_ms: 1, + partial_recovery_reason: None, + success: Some(true), + finish_reason: None, + has_final_response: None, + } + } + + fn dialog_turn_failed() -> AgenticEvent { + AgenticEvent::DialogTurnFailed { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + error: "failed".to_string(), + error_category: None, + error_detail: None, + } + } +} diff --git a/src/apps/desktop/src/runtime/acp_permission_observer.rs b/src/apps/desktop/src/runtime/acp_permission_observer.rs new file mode 100644 index 0000000000..b25d91ff70 --- /dev/null +++ b/src/apps/desktop/src/runtime/acp_permission_observer.rs @@ -0,0 +1,82 @@ +//! Desktop observer that mirrors ACP permission requests into the shared mailbox +//! and keeps the existing Web UI event name for local tool-card UX. + +use std::sync::Arc; +use std::time::Duration; + +use bitfun_acp::client::AcpPermissionObserver; +use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; +use bitfun_services_integrations::remote_connect::{ + acp_permission_now_ms, AcpPermissionMailbox, AcpPermissionMailboxEntry, +}; +use log::warn; +use serde_json::json; + +pub(crate) struct DesktopAcpPermissionObserver { + mailbox: Arc, +} + +impl DesktopAcpPermissionObserver { + pub(crate) fn new(mailbox: Arc) -> Self { + Self { mailbox } + } +} + +impl AcpPermissionObserver for DesktopAcpPermissionObserver { + fn on_permission_requested( + &self, + permission_id: &str, + session_id: &str, + tool_call: &serde_json::Value, + options: &serde_json::Value, + timeout: Duration, + ) { + let created_at_ms = acp_permission_now_ms(); + let expires_at_ms = created_at_ms.saturating_add(timeout.as_millis() as u64); + self.mailbox.insert(AcpPermissionMailboxEntry { + permission_id: permission_id.to_string(), + session_id: session_id.to_string(), + tool_call: tool_call.clone(), + options: options.clone(), + created_at_ms, + expires_at_ms, + }); + + let payload = json!({ + "permissionId": permission_id, + "sessionId": session_id, + "toolCall": tool_call, + "options": options, + }); + // Local Web UI still listens on this event name; the mailbox is the + // shared source for Remote Poll and remote respond. + tokio::spawn(async move { + if let Err(error) = emit_global_event(BackendEvent::Custom { + event_name: "backend-event-acppermissionrequest".to_string(), + payload, + }) + .await + { + warn!("Failed to emit ACP permission request to local UI: {error}"); + } + }); + } + + fn on_permission_resolved(&self, permission_id: &str) { + let _ = self.mailbox.remove(permission_id); + } +} + +#[cfg(test)] +mod tests { + use bitfun_acp::client::{is_acp_permission_id, ACP_PERMISSION_ID_PREFIX}; + + #[test] + fn permission_prefix_constant_matches_minted_ids() { + assert!(is_acp_permission_id(&format!( + "{ACP_PERMISSION_ID_PREFIX}{}", + uuid::Uuid::new_v4() + ))); + assert!(!is_acp_permission_id("native-tool-1")); + } +} diff --git a/src/apps/desktop/src/runtime/acp_projection_writer.rs b/src/apps/desktop/src/runtime/acp_projection_writer.rs new file mode 100644 index 0000000000..415fd80cf5 --- /dev/null +++ b/src/apps/desktop/src/runtime/acp_projection_writer.rs @@ -0,0 +1,2089 @@ +//! Durable ACP transcript writer. +//! +//! Consumes already-ordered `ExternalAcp` envelopes and writes settled turns +//! through the existing externally-projected persistence path. It does not load +//! ACP sessions into SessionManager. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use bitfun_core::agentic::events::{EventQueue, EventSubscriber}; +use bitfun_core::service::remote_connect::remote_server::get_global_dispatcher; +use bitfun_core::service::session::{ + DialogTurnData, DialogTurnRecoveryData, DialogTurnRecoveryStatus, ModelRoundData, TextItemData, + ThinkingItemData, ToolCallData, ToolItemData, ToolResultData, TurnStatus, UserMessageData, +}; +use bitfun_core_types::ReasoningContentKind; +use bitfun_events::{ + AgenticEvent, AgenticEventEnvelope, AgenticEventOrigin, AgenticEventPriority, ToolEventData, + ToolEventIdentity, +}; +use log::{error, warn}; +use tokio::sync::Mutex as AsyncMutex; + +use super::session_application::{DesktopSessionApplication, DesktopSessionScopeRequest}; + +/// Mid-turn streaming checkpoints are "fresh enough", not every token. +#[cfg(not(test))] +const STREAMING_CHECKPOINT_MIN_INTERVAL: Duration = Duration::from_secs(2); +const STREAMING_CHECKPOINT_MIN_BYTES: usize = 4 * 1024; + +#[cfg(test)] +fn streaming_checkpoint_min_interval() -> Duration { + Duration::from_millis(20) +} + +#[cfg(not(test))] +fn streaming_checkpoint_min_interval() -> Duration { + STREAMING_CHECKPOINT_MIN_INTERVAL +} + +#[async_trait] +pub(crate) trait AcpTurnPersister: Send + Sync + 'static { + async fn next_turn_index( + &self, + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + session_id: &str, + turn_id: &str, + ) -> Result; + + async fn persist_turn( + &self, + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + turn: DialogTurnData, + ) -> Result<(), String>; + + async fn load_turns( + &self, + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + session_id: &str, + ) -> Result, String>; + + fn mark_history_unreadable(&self, session_id: &str); +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct AcpSessionScope { + workspace_path: String, + remote_connection_id: Option, + remote_ssh_host: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum AcpSessionScopeRegistrationError { + Conflict { session_id: String }, + Recovery(String), +} + +impl std::fmt::Display for AcpSessionScopeRegistrationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Conflict { session_id } => write!( + formatter, + "ACP projection scope conflicts with the registered session scope: {session_id}" + ), + Self::Recovery(error) => formatter.write_str(error), + } + } +} + +impl std::error::Error for AcpSessionScopeRegistrationError {} + +#[derive(Clone)] +struct AcpTurnDraft { + session_id: String, + turn_id: String, + /// Resolved once on `DialogTurnStarted` (reuse by turn_id for §10.6). + turn_index: usize, + user_input: String, + original_user_input: Option, + user_message_metadata: Option, + rounds: Vec, + last_checkpoint_at: Option, + bytes_since_checkpoint: usize, +} + +pub(crate) struct AcpDurableProjectionWriter

{ + queue: Arc, + persister: Arc

, + scope_registration: AsyncMutex<()>, + scopes: Mutex>, + drafts: Mutex>, +} + +impl AcpDurableProjectionWriter

{ + pub(crate) fn new(queue: Arc, persister: P) -> Arc { + Arc::new(Self { + queue, + persister: Arc::new(persister), + scope_registration: AsyncMutex::new(()), + scopes: Mutex::new(HashMap::new()), + drafts: Mutex::new(HashMap::new()), + }) + } + + pub(crate) async fn ensure_session_scope( + &self, + session_id: &str, + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + ) -> Result<(), AcpSessionScopeRegistrationError> { + let requested = AcpSessionScope { + workspace_path: workspace_path.to_string(), + remote_connection_id: remote_connection_id.map(ToOwned::to_owned), + remote_ssh_host: remote_ssh_host.map(ToOwned::to_owned), + }; + let _registration = self.scope_registration.lock().await; + { + let mut scopes = self.scopes.lock().expect("ACP projection scopes"); + if let Some(existing) = scopes.get(session_id) { + if existing == &requested { + return Ok(()); + } + return Err(AcpSessionScopeRegistrationError::Conflict { + session_id: session_id.to_string(), + }); + } + scopes.insert(session_id.to_string(), requested.clone()); + } + + if let Err(error) = self.recover_abandoned_turns(session_id).await { + let mut scopes = self.scopes.lock().expect("ACP projection scopes"); + if scopes.get(session_id) == Some(&requested) { + scopes.remove(session_id); + } + return Err(AcpSessionScopeRegistrationError::Recovery(error)); + } + Ok(()) + } + + async fn handle_envelope(&self, envelope: &AgenticEventEnvelope) -> Result<(), String> { + if envelope.origin != AgenticEventOrigin::ExternalAcp { + return Ok(()); + } + match &envelope.event { + AgenticEvent::SessionCreated { + session_id, + workspace_path, + remote_connection_id, + remote_ssh_host, + .. + } => { + let Some(workspace_path) = workspace_path.clone() else { + return Err(format!( + "ACP SessionCreated is missing workspace_path: {session_id}" + )); + }; + self.ensure_session_scope( + session_id, + &workspace_path, + remote_connection_id.as_deref(), + remote_ssh_host.as_deref(), + ) + .await + .map_err(|error| error.to_string()) + } + AgenticEvent::DialogTurnStarted { + session_id, + turn_id, + user_input, + original_user_input, + user_message_metadata, + .. + } => { + let scope = self.scope_for(session_id)?; + let turn_index = self + .persister + .next_turn_index( + &scope.workspace_path, + scope.remote_connection_id.as_deref(), + scope.remote_ssh_host.as_deref(), + session_id, + turn_id, + ) + .await?; + self.drafts.lock().expect("ACP projection drafts").insert( + (session_id.clone(), turn_id.clone()), + AcpTurnDraft { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + turn_index, + user_input: user_input.clone(), + original_user_input: original_user_input.clone(), + user_message_metadata: user_message_metadata.clone(), + rounds: Vec::new(), + last_checkpoint_at: None, + bytes_since_checkpoint: 0, + }, + ); + self.checkpoint_draft(session_id, turn_id).await + } + AgenticEvent::ModelRoundStarted { + session_id, + turn_id, + round_id, + round_index, + .. + } => { + self.mutate_draft(session_id, turn_id, |draft| { + let round = current_round(draft, round_id); + round.round_index = *round_index; + })?; + self.checkpoint_draft(session_id, turn_id).await + } + AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + text, + .. + } => { + self.mutate_draft(session_id, turn_id, |draft| { + append_text(draft, round_id, text); + })?; + self.maybe_checkpoint_streaming(session_id, turn_id).await + } + AgenticEvent::ThinkingChunk { + session_id, + turn_id, + round_id, + content, + reasoning_kind, + .. + } => { + self.mutate_draft(session_id, turn_id, |draft| { + append_thinking(draft, round_id, content, *reasoning_kind); + })?; + self.maybe_checkpoint_streaming(session_id, turn_id).await + } + AgenticEvent::ToolEvent { + session_id, + turn_id, + round_id, + tool_event, + .. + } => { + self.mutate_draft(session_id, turn_id, |draft| { + apply_tool(draft, round_id, tool_event); + })?; + self.checkpoint_draft(session_id, turn_id).await + } + AgenticEvent::DialogTurnCompleted { + session_id, + turn_id, + duration_ms, + finish_reason, + has_final_response, + success, + .. + } => { + self.settle_turn( + session_id, + turn_id, + TurnStatus::Completed, + None, + *duration_ms, + finish_reason.clone(), + *has_final_response, + *success, + ) + .await + } + AgenticEvent::DialogTurnFailed { + session_id, + turn_id, + error, + .. + } => { + self.settle_turn( + session_id, + turn_id, + TurnStatus::Error, + Some(error.clone()), + 0, + None, + None, + None, + ) + .await + } + AgenticEvent::DialogTurnCancelled { + session_id, + turn_id, + } => { + self.settle_turn( + session_id, + turn_id, + TurnStatus::Cancelled, + None, + 0, + None, + None, + None, + ) + .await + } + _ => Ok(()), + } + } + + fn mutate_draft( + &self, + session_id: &str, + turn_id: &str, + update: impl FnOnce(&mut AcpTurnDraft), + ) -> Result<(), String> { + let mut drafts = self.drafts.lock().expect("ACP projection drafts"); + let draft = drafts + .get_mut(&(session_id.to_string(), turn_id.to_string())) + .ok_or_else(|| { + format!("ACP projection has no draft for session={session_id} turn={turn_id}") + })?; + update(draft); + Ok(()) + } + + fn clone_draft(&self, session_id: &str, turn_id: &str) -> Result { + self.drafts + .lock() + .expect("ACP projection drafts") + .get(&(session_id.to_string(), turn_id.to_string())) + .cloned() + .ok_or_else(|| { + format!( + "ACP projection has no draft to persist for session={session_id} turn={turn_id}" + ) + }) + } + + fn remove_draft(&self, session_id: &str, turn_id: &str) { + self.drafts + .lock() + .expect("ACP projection drafts") + .remove(&(session_id.to_string(), turn_id.to_string())); + } + + fn scope_for(&self, session_id: &str) -> Result { + self.scopes + .lock() + .expect("ACP projection scopes") + .get(session_id) + .cloned() + .ok_or_else(|| format!("ACP projection is missing workspace scope for {session_id}")) + } + + async fn persist_snapshot( + &self, + session_id: &str, + turn_id: &str, + turn: DialogTurnData, + emit_history_changed: bool, + mark_unreadable_on_failure: bool, + ) -> Result<(), String> { + let scope = self.scope_for(session_id)?; + if let Err(persist_error) = self + .persister + .persist_turn( + &scope.workspace_path, + scope.remote_connection_id.as_deref(), + scope.remote_ssh_host.as_deref(), + turn, + ) + .await + { + if mark_unreadable_on_failure { + self.persister.mark_history_unreadable(session_id); + } + return Err(persist_error); + } + if emit_history_changed { + if let Err(error) = self + .queue + .enqueue_with_origin( + AgenticEvent::SessionHistoryChanged { + session_id: session_id.to_string(), + settled_turn_id: Some(turn_id.to_string()), + }, + Some(AgenticEventPriority::Normal), + AgenticEventOrigin::ExternalAcp, + ) + .await + { + if mark_unreadable_on_failure { + self.persister.mark_history_unreadable(session_id); + } + return Err(error.to_string()); + } + } + Ok(()) + } + + fn build_turn_from_draft( + &self, + session_id: &str, + turn_id: &str, + status: TurnStatus, + error: Option, + duration_ms: Option, + finish_reason: Option, + has_final_response: Option, + success: Option, + recovery: Option, + ) -> Result { + let draft = self.clone_draft(session_id, turn_id)?; + let mut turn = DialogTurnData::new( + draft.turn_id.clone(), + draft.turn_index, + draft.session_id.clone(), + UserMessageData { + id: format!("{}-user", draft.turn_id), + content: draft + .original_user_input + .clone() + .unwrap_or_else(|| draft.user_input.clone()), + timestamp: now_ms(), + metadata: draft.user_message_metadata.clone(), + }, + ); + turn.model_rounds = draft.rounds; + let in_progress = matches!(status, TurnStatus::InProgress); + turn.status = status; + turn.duration_ms = duration_ms; + if !in_progress { + turn.end_time = Some(now_ms()); + } + turn.error = error; + turn.finish_reason = finish_reason; + turn.has_final_response = has_final_response.or(success); + turn.recovery = recovery; + Ok(turn) + } + + fn should_checkpoint_streaming(&self, session_id: &str, turn_id: &str) -> Result { + let drafts = self.drafts.lock().expect("ACP projection drafts"); + let draft = drafts + .get(&(session_id.to_string(), turn_id.to_string())) + .ok_or_else(|| { + format!("ACP projection has no draft for session={session_id} turn={turn_id}") + })?; + Ok(should_checkpoint_streaming(draft)) + } + + async fn maybe_checkpoint_streaming( + &self, + session_id: &str, + turn_id: &str, + ) -> Result<(), String> { + if !self.should_checkpoint_streaming(session_id, turn_id)? { + return Ok(()); + } + self.checkpoint_draft(session_id, turn_id).await + } + + async fn checkpoint_draft(&self, session_id: &str, turn_id: &str) -> Result<(), String> { + let turn = self.build_turn_from_draft( + session_id, + turn_id, + TurnStatus::InProgress, + None, + None, + None, + None, + None, + None, + )?; + match self + .persist_snapshot(session_id, turn_id, turn, false, false) + .await + { + Ok(()) => { + self.mutate_draft(session_id, turn_id, mark_checkpointed)?; + Ok(()) + } + Err(error) => { + // Mid-turn checkpoints are best-effort: keep the draft and do not + // declare the whole session history unreadable for a transient IO fail. + // Still advance throttle state so a sustained disk failure does not + // retry a doomed write on every subsequent streaming chunk. + warn!( + "ACP in-progress checkpoint failed: session_id={session_id}, turn_id={turn_id}, error={error}" + ); + self.mutate_draft(session_id, turn_id, mark_checkpointed)?; + Ok(()) + } + } + } + + async fn settle_turn( + &self, + session_id: &str, + turn_id: &str, + status: TurnStatus, + error: Option, + duration_ms: u64, + finish_reason: Option, + has_final_response: Option, + success: Option, + ) -> Result<(), String> { + let turn = self.build_turn_from_draft( + session_id, + turn_id, + status, + error, + Some(duration_ms), + finish_reason, + has_final_response, + success, + None, + )?; + self.persist_snapshot(session_id, turn_id, turn, true, true) + .await?; + self.remove_draft(session_id, turn_id); + Ok(()) + } + + async fn persist_interrupted_draft( + &self, + session_id: &str, + turn_id: &str, + ) -> Result<(), String> { + let turn = self.build_turn_from_draft( + session_id, + turn_id, + TurnStatus::Cancelled, + None, + None, + Some("interrupted".to_string()), + None, + None, + Some(interrupted_recovery()), + )?; + self.persist_snapshot(session_id, turn_id, turn, true, true) + .await?; + self.remove_draft(session_id, turn_id); + Ok(()) + } + + async fn recover_abandoned_turns(&self, session_id: &str) -> Result<(), String> { + let scope = self.scope_for(session_id)?; + let turns = self + .persister + .load_turns( + &scope.workspace_path, + scope.remote_connection_id.as_deref(), + scope.remote_ssh_host.as_deref(), + session_id, + ) + .await?; + let live_turn_ids: Vec = self + .drafts + .lock() + .expect("ACP projection drafts") + .keys() + .filter(|(draft_session, _)| draft_session == session_id) + .map(|(_, turn_id)| turn_id.clone()) + .collect(); + for mut turn in turns { + if turn.status != TurnStatus::InProgress { + continue; + } + if live_turn_ids.iter().any(|turn_id| turn_id == &turn.turn_id) { + continue; + } + apply_interrupted(&mut turn); + let turn_id = turn.turn_id.clone(); + self.persist_snapshot(session_id, &turn_id, turn, true, true) + .await?; + } + Ok(()) + } + + pub(crate) async fn flush_interrupted(&self) -> Result<(), String> { + let keys: Vec<(String, String)> = self + .drafts + .lock() + .expect("ACP projection drafts") + .keys() + .cloned() + .collect(); + let mut first_error = None; + for (session_id, turn_id) in keys { + if let Err(error) = self.persist_interrupted_draft(&session_id, &turn_id).await { + error!( + "ACP interrupted flush failed: session_id={session_id}, turn_id={turn_id}, error={error}" + ); + if first_error.is_none() { + first_error = Some(error); + } + } + } + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + + #[cfg(test)] + fn has_draft(&self, session_id: &str, turn_id: &str) -> bool { + self.drafts + .lock() + .expect("ACP projection drafts") + .contains_key(&(session_id.to_string(), turn_id.to_string())) + } +} + +#[async_trait] +impl EventSubscriber for AcpDurableProjectionWriter

{ + async fn on_event( + &self, + _event: &AgenticEvent, + ) -> bitfun_agent_runtime::event_bus::EventSubscriberResult { + Ok(()) + } + + async fn on_envelope( + &self, + envelope: &AgenticEventEnvelope, + ) -> bitfun_agent_runtime::event_bus::EventSubscriberResult { + if let Err(error) = self.handle_envelope(envelope).await { + error!("ACP durable projection failed: {error}"); + return Err(bitfun_agent_runtime::event_bus::EventBusError::subscriber( + error, + )); + } + Ok(()) + } +} + +#[async_trait] +impl AcpTurnPersister for DesktopSessionApplication { + async fn next_turn_index( + &self, + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + session_id: &str, + turn_id: &str, + ) -> Result { + let request = DesktopSessionScopeRequest { + workspace_path: workspace_path.to_string(), + remote_connection_id: remote_connection_id.map(ToString::to_string), + remote_ssh_host: remote_ssh_host.map(ToString::to_string), + }; + let turns = self + .load_session_turns(request, session_id, None) + .await + .map_err(|error| error.to_string())?; + if let Some(existing) = turns.iter().find(|turn| turn.turn_id == turn_id) { + return Ok(existing.turn_index); + } + Ok(turns.len()) + } + + async fn persist_turn( + &self, + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + turn: DialogTurnData, + ) -> Result<(), String> { + let request = DesktopSessionScopeRequest { + workspace_path: workspace_path.to_string(), + remote_connection_id: remote_connection_id.map(ToString::to_string), + remote_ssh_host: remote_ssh_host.map(ToString::to_string), + }; + self.save_session_turn(request, &turn) + .await + .map_err(|error| error.to_string()) + } + + async fn load_turns( + &self, + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + session_id: &str, + ) -> Result, String> { + let request = DesktopSessionScopeRequest { + workspace_path: workspace_path.to_string(), + remote_connection_id: remote_connection_id.map(ToString::to_string), + remote_ssh_host: remote_ssh_host.map(ToString::to_string), + }; + self.load_session_turns(request, session_id, None) + .await + .map_err(|error| error.to_string()) + } + + fn mark_history_unreadable(&self, session_id: &str) { + if let Some(tracker) = + get_global_dispatcher().and_then(|dispatcher| dispatcher.get_tracker(session_id)) + { + tracker.require_history_snapshot(); + return; + } + warn!( + "ACP durable projection failed with no remote tracker to mark snapshot-required: {session_id}" + ); + } +} + +fn empty_round(round_id: &str, turn_id: &str, round_index: usize) -> ModelRoundData { + let now = now_ms(); + ModelRoundData { + id: round_id.to_string(), + turn_id: turn_id.to_string(), + round_index, + round_group_id: None, + timestamp: now, + text_items: Vec::new(), + tool_items: Vec::new(), + thinking_items: Vec::new(), + start_time: now, + end_time: None, + duration_ms: None, + provider_id: None, + model_config_id: None, + effective_model_name: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + attempt_diagnostics: Vec::new(), + failure_category: None, + token_details: None, + status: "completed".to_string(), + } +} + +fn current_round<'a>(draft: &'a mut AcpTurnDraft, round_id: &str) -> &'a mut ModelRoundData { + if let Some(index) = draft.rounds.iter().position(|round| round.id == round_id) { + return &mut draft.rounds[index]; + } + let index = draft.rounds.len(); + draft + .rounds + .push(empty_round(round_id, &draft.turn_id, index)); + draft.rounds.last_mut().expect("round just inserted") +} + +fn append_text(draft: &mut AcpTurnDraft, round_id: &str, text: &str) { + draft.bytes_since_checkpoint = draft.bytes_since_checkpoint.saturating_add(text.len()); + let round = current_round(draft, round_id); + if let Some(last) = round.text_items.last_mut() { + last.content.push_str(text); + last.is_streaming = false; + return; + } + round.text_items.push(TextItemData { + id: format!("{}-text", round.id), + content: text.to_string(), + is_streaming: false, + timestamp: now_ms(), + is_markdown: true, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + status: None, + attempt_id: None, + attempt_index: None, + }); +} + +fn append_thinking( + draft: &mut AcpTurnDraft, + round_id: &str, + content: &str, + reasoning_kind: Option, +) { + draft.bytes_since_checkpoint = draft.bytes_since_checkpoint.saturating_add(content.len()); + let round = current_round(draft, round_id); + if let Some(last) = round.thinking_items.last_mut() { + if last.reasoning_kind == reasoning_kind { + last.content.push_str(content); + last.is_streaming = false; + return; + } + } + let thinking_index = round.thinking_items.len(); + let thinking_id = if thinking_index == 0 { + format!("{}-thinking", round.id) + } else { + format!("{}-thinking-{thinking_index}", round.id) + }; + round.thinking_items.push(ThinkingItemData { + id: thinking_id, + content: content.to_string(), + reasoning_kind, + is_streaming: false, + is_collapsed: true, + timestamp: now_ms(), + order_index: None, + status: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + attempt_id: None, + attempt_index: None, + }); +} + +fn apply_tool(draft: &mut AcpTurnDraft, round_id: &str, tool_event: &ToolEventData) { + let identity = tool_identity(tool_event); + let round = current_round(draft, round_id); + let existing = round + .tool_items + .iter_mut() + .find(|item| item.id == identity.tool_id); + match tool_event { + ToolEventData::Started { params, .. } + | ToolEventData::ConfirmationNeeded { params, .. } => { + if let Some(item) = existing { + item.tool_call.input = params.clone(); + item.status = Some("running".to_string()); + return; + } + round.tool_items.push(ToolItemData { + id: identity.tool_id.clone(), + tool_name: identity.tool_name.clone(), + tool_call: ToolCallData { + input: params.clone(), + id: identity.tool_id.clone(), + }, + tool_result: None, + ai_intent: None, + start_time: now_ms(), + end_time: None, + duration_ms: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + subagent_dialog_turn_id: None, + attempt_id: None, + attempt_index: None, + subagent_model_id: None, + subagent_model_display_name: None, + status: Some("running".to_string()), + interruption_reason: None, + }); + } + ToolEventData::Completed { + result, + result_for_assistant, + duration_ms, + .. + } => { + if let Some(item) = existing { + item.tool_result = Some(ToolResultData { + result: result.clone(), + success: true, + result_for_assistant: result_for_assistant.clone(), + image_attachments: None, + error: None, + duration_ms: Some(*duration_ms), + }); + item.duration_ms = Some(*duration_ms); + item.status = Some("completed".to_string()); + } + } + ToolEventData::Failed { + error, duration_ms, .. + } => { + if let Some(item) = existing { + item.tool_result = Some(ToolResultData { + result: serde_json::Value::Null, + success: false, + result_for_assistant: None, + image_attachments: None, + error: Some(error.clone()), + duration_ms: *duration_ms, + }); + item.status = Some("failed".to_string()); + } + } + ToolEventData::Cancelled { reason, .. } => { + if let Some(item) = existing { + item.status = Some("cancelled".to_string()); + item.interruption_reason = Some(reason.clone()); + } + } + _ => {} + } +} + +fn tool_identity(tool_event: &ToolEventData) -> &ToolEventIdentity { + match tool_event { + ToolEventData::EarlyDetected { identity } + | ToolEventData::ParamsPartial { identity, .. } + | ToolEventData::Queued { identity, .. } + | ToolEventData::Waiting { identity, .. } + | ToolEventData::Started { identity, .. } + | ToolEventData::Progress { identity, .. } + | ToolEventData::Streaming { identity, .. } + | ToolEventData::StreamChunk { identity, .. } + | ToolEventData::ConfirmationNeeded { identity, .. } + | ToolEventData::Confirmed { identity } + | ToolEventData::Rejected { identity } + | ToolEventData::Completed { identity, .. } + | ToolEventData::Failed { identity, .. } + | ToolEventData::Cancelled { identity, .. } => identity, + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn should_checkpoint_streaming(draft: &AcpTurnDraft) -> bool { + if draft.bytes_since_checkpoint >= STREAMING_CHECKPOINT_MIN_BYTES { + return true; + } + match draft.last_checkpoint_at { + None => true, + Some(at) => at.elapsed() >= streaming_checkpoint_min_interval(), + } +} + +fn mark_checkpointed(draft: &mut AcpTurnDraft) { + draft.last_checkpoint_at = Some(Instant::now()); + draft.bytes_since_checkpoint = 0; +} + +fn interrupted_recovery() -> DialogTurnRecoveryData { + DialogTurnRecoveryData { + status: DialogTurnRecoveryStatus::Interrupted, + execution_generation: 1, + resume_count: 0, + interrupted_at: Some(now_ms()), + model_id: None, + } +} + +fn apply_interrupted(turn: &mut DialogTurnData) { + turn.status = TurnStatus::Cancelled; + turn.end_time = Some(now_ms()); + turn.finish_reason = Some("interrupted".to_string()); + turn.recovery = Some(interrupted_recovery()); +} + +static DESKTOP_ACP_WRITER: OnceLock>> = + OnceLock::new(); + +pub(crate) fn install_desktop_acp_writer( + writer: Arc>, +) -> Arc> { + if DESKTOP_ACP_WRITER.set(writer.clone()).is_err() { + warn!("ACP durable projection writer was installed twice"); + } + writer +} + +pub(crate) fn flush_desktop_acp_writer_blocking() { + let Some(writer) = DESKTOP_ACP_WRITER.get() else { + return; + }; + let writer = Arc::clone(writer); + let join = std::thread::Builder::new() + .name("acp-projection-flush".to_string()) + .spawn(move || { + match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime.block_on(writer.flush_interrupted()), + Err(error) => Err(format!("ACP interrupted flush runtime failed: {error}")), + } + }); + match join { + Ok(handle) => match handle.join() { + Ok(Ok(())) => {} + Ok(Err(error)) => error!("ACP interrupted flush failed: {error}"), + Err(_) => error!("ACP interrupted flush thread panicked"), + }, + Err(error) => error!("ACP interrupted flush thread failed to start: {error}"), + } +} + +#[cfg(test)] +mod tests { + use super::{AcpDurableProjectionWriter, AcpSessionScopeRegistrationError, AcpTurnPersister}; + use bitfun_core::agentic::events::{EventQueue, EventSubscriber}; + use bitfun_core::service::session::{DialogTurnData, DialogTurnRecoveryStatus, TurnStatus}; + use bitfun_events::{ + AgenticEvent, AgenticEventEnvelope, AgenticEventOrigin, AgenticEventPriority, + ModelRoundIdentity, ToolEventData, ToolEventIdentity, + }; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + use tokio::sync::Notify; + + #[derive(Default)] + struct RecordingPersister { + turns: Mutex>, + unreadable: Mutex>, + fail_next: Mutex, + fail_next_load: Mutex, + index_lookups: Mutex, + persist_calls: Mutex, + load_calls: Mutex, + load_scopes: Mutex, Option, String)>>, + block_next_load: AtomicBool, + load_started: Notify, + release_load: Notify, + } + + #[async_trait::async_trait] + impl AcpTurnPersister for Arc { + async fn next_turn_index( + &self, + _workspace_path: &str, + _remote_connection_id: Option<&str>, + _remote_ssh_host: Option<&str>, + _session_id: &str, + turn_id: &str, + ) -> Result { + *self.index_lookups.lock().expect("index lookups") += 1; + let turns = self.turns.lock().expect("turns"); + Ok(turns + .iter() + .find(|turn| turn.turn_id == turn_id) + .map(|turn| turn.turn_index) + .unwrap_or(turns.len())) + } + + async fn persist_turn( + &self, + _workspace_path: &str, + _remote_connection_id: Option<&str>, + _remote_ssh_host: Option<&str>, + turn: DialogTurnData, + ) -> Result<(), String> { + *self.persist_calls.lock().expect("persist calls") += 1; + if *self.fail_next.lock().expect("fail flag") { + *self.fail_next.lock().expect("fail flag") = false; + return Err("disk full".to_string()); + } + let mut turns = self.turns.lock().expect("turns"); + if let Some(existing) = turns + .iter_mut() + .find(|existing| existing.turn_id == turn.turn_id) + { + *existing = turn; + } else { + turns.push(turn); + } + Ok(()) + } + + async fn load_turns( + &self, + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + session_id: &str, + ) -> Result, String> { + *self.load_calls.lock().expect("load calls") += 1; + self.load_scopes.lock().expect("load scopes").push(( + workspace_path.to_string(), + remote_connection_id.map(ToOwned::to_owned), + remote_ssh_host.map(ToOwned::to_owned), + session_id.to_string(), + )); + let fail = { + let mut fail_next_load = self.fail_next_load.lock().expect("load fail flag"); + std::mem::take(&mut *fail_next_load) + }; + if fail { + return Err("load failed".to_string()); + } + if self.block_next_load.swap(false, Ordering::SeqCst) { + self.load_started.notify_one(); + self.release_load.notified().await; + } + Ok(self.turns.lock().expect("turns").clone()) + } + + fn mark_history_unreadable(&self, session_id: &str) { + self.unreadable + .lock() + .expect("unreadable") + .push(session_id.to_string()); + } + } + + fn envelope(origin: AgenticEventOrigin, event: AgenticEvent) -> AgenticEventEnvelope { + AgenticEventEnvelope::new_with_origin(event, AgenticEventPriority::Normal, origin) + } + + async fn drive_completed_turn(writer: &AcpDurableProjectionWriter>) { + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::SessionCreated { + session_id: "acp-1".to_string(), + session_name: "ACP".to_string(), + agent_type: "acp:gemini".to_string(), + workspace_path: Some("/tmp/ws".to_string()), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + }, + )) + .await + .expect("session"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::DialogTurnStarted { + session_id: "acp-1".to_string(), + turn_id: "turn-1".to_string(), + turn_index: 0, + user_input: "hello".to_string(), + original_user_input: None, + user_message_metadata: None, + }, + )) + .await + .expect("start"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::ModelRoundStarted { + session_id: "acp-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + round_group_id: None, + round_index: 0, + identity: ModelRoundIdentity::External { + provider: "acp".to_string(), + client_id: "gemini".to_string(), + model_id: None, + display_name: None, + }, + render_hints: None, + }, + )) + .await + .expect("round"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::TextChunk { + session_id: "acp-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "hi".to_string(), + }, + )) + .await + .expect("text"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::ToolEvent { + session_id: "acp-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Started { + identity: ToolEventIdentity::direct("tool-1", "read"), + params: serde_json::json!({"path": "a.rs"}), + timeout_seconds: None, + }, + }, + )) + .await + .expect("tool start"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::ToolEvent { + session_id: "acp-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Completed { + identity: ToolEventIdentity::direct("tool-1", "read"), + result: serde_json::json!("ok"), + result_for_assistant: Some("ok".to_string()), + image_attachments: None, + duration_ms: 3, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + }, + }, + )) + .await + .expect("tool complete"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::DialogTurnCompleted { + session_id: "acp-1".to_string(), + turn_id: "turn-1".to_string(), + total_rounds: 1, + total_tools: 1, + duration_ms: 10, + partial_recovery_reason: None, + success: Some(true), + finish_reason: Some("complete".to_string()), + has_final_response: Some(true), + }, + )) + .await + .expect("complete"); + } + + #[tokio::test] + async fn persists_settled_acp_turn_and_emits_history_fence() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue.clone(), persister.clone()); + drive_completed_turn(&writer).await; + + let turns = persister.turns.lock().expect("turns"); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].turn_id, "turn-1"); + assert_eq!(turns[0].status, TurnStatus::Completed); + assert_eq!(turns[0].user_message.content, "hello"); + assert_eq!(turns[0].model_rounds[0].text_items[0].content, "hi"); + assert_eq!(turns[0].model_rounds[0].tool_items[0].id, "tool-1"); + assert!(turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .is_some_and(|result| result.success)); + + let batch = queue.dequeue_configured_batch().await; + assert!(batch.iter().any(|envelope| matches!( + &envelope.event, + AgenticEvent::SessionHistoryChanged { + session_id, + settled_turn_id: Some(turn_id), + } if session_id == "acp-1" && turn_id == "turn-1" + ))); + assert_eq!(batch[0].origin, AgenticEventOrigin::ExternalAcp); + } + + #[tokio::test] + async fn repeated_round_start_updates_the_existing_round() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue, persister.clone()); + open_session_and_turn(&writer, "turn-repeat").await; + + let round_started = || AgenticEvent::ModelRoundStarted { + session_id: "acp-1".to_string(), + turn_id: "turn-repeat".to_string(), + round_id: "round-1".to_string(), + round_group_id: None, + round_index: 0, + identity: ModelRoundIdentity::External { + provider: "acp".to_string(), + client_id: "gemini".to_string(), + model_id: None, + display_name: None, + }, + render_hints: None, + }; + writer + .on_envelope(&envelope(AgenticEventOrigin::ExternalAcp, round_started())) + .await + .expect("first round start"); + writer + .on_envelope(&envelope(AgenticEventOrigin::ExternalAcp, round_started())) + .await + .expect("repeated round start"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::ToolEvent { + session_id: "acp-1".to_string(), + turn_id: "turn-repeat".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Started { + identity: ToolEventIdentity::direct("tool-1", "Bash"), + params: serde_json::json!({"command": "sleep 30"}), + timeout_seconds: None, + }, + }, + )) + .await + .expect("tool start"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::ToolEvent { + session_id: "acp-1".to_string(), + turn_id: "turn-repeat".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Failed { + identity: ToolEventIdentity::direct("tool-1", "Bash"), + error: "tool call aborted".to_string(), + duration_ms: Some(10), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + }, + }, + )) + .await + .expect("tool failure"); + + let turns = persister.turns.lock().expect("turns"); + assert_eq!(turns[0].model_rounds.len(), 1); + assert_eq!(turns[0].model_rounds[0].tool_items.len(), 1); + assert_eq!( + turns[0].model_rounds[0].tool_items[0].status.as_deref(), + Some("failed") + ); + } + + #[tokio::test] + async fn ignores_native_envelopes() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue, persister.clone()); + writer + .on_envelope(&envelope( + AgenticEventOrigin::NativeRuntime, + AgenticEvent::DialogTurnCompleted { + session_id: "native-1".to_string(), + turn_id: "turn-1".to_string(), + total_rounds: 1, + total_tools: 0, + duration_ms: 1, + partial_recovery_reason: None, + success: Some(true), + finish_reason: None, + has_final_response: None, + }, + )) + .await + .expect("native ignored"); + assert!(persister.turns.lock().expect("turns").is_empty()); + } + + #[tokio::test] + async fn existing_session_scope_registration_recovers_once_before_turn_start() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue, persister.clone()); + + writer + .ensure_session_scope( + "acp-existing", + "/remote/workspace", + Some("connection-1"), + Some("remote.example"), + ) + .await + .expect("register existing session scope"); + writer + .ensure_session_scope( + "acp-existing", + "/remote/workspace", + Some("connection-1"), + Some("remote.example"), + ) + .await + .expect("same scope is idempotent"); + + assert_eq!(*persister.load_calls.lock().expect("load calls"), 1); + assert_eq!( + persister + .load_scopes + .lock() + .expect("load scopes") + .as_slice(), + [( + "/remote/workspace".to_string(), + Some("connection-1".to_string()), + Some("remote.example".to_string()), + "acp-existing".to_string(), + )] + ); + + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::DialogTurnStarted { + session_id: "acp-existing".to_string(), + turn_id: "turn-after-restart".to_string(), + turn_index: 0, + user_input: "continue".to_string(), + original_user_input: None, + user_message_metadata: None, + }, + )) + .await + .expect("turn starts after explicit scope registration"); + assert!(writer.has_draft("acp-existing", "turn-after-restart")); + assert_eq!(*persister.load_calls.lock().expect("load calls"), 1); + } + + #[tokio::test] + async fn conflicting_session_scope_fails_loud_without_recovery() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue, persister.clone()); + + writer + .ensure_session_scope("acp-1", "/tmp/ws", None, None) + .await + .expect("initial scope"); + let error = writer + .ensure_session_scope( + "acp-1", + "/remote/ws", + Some("connection-1"), + Some("remote.example"), + ) + .await + .expect_err("conflicting scope must fail"); + + assert_eq!( + error, + AcpSessionScopeRegistrationError::Conflict { + session_id: "acp-1".to_string(), + } + ); + assert_eq!(*persister.load_calls.lock().expect("load calls"), 1); + } + + #[tokio::test] + async fn failed_scope_recovery_rolls_back_and_can_retry() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue, persister.clone()); + *persister.fail_next_load.lock().expect("load fail flag") = true; + + let error = writer + .ensure_session_scope("acp-1", "/tmp/ws", None, None) + .await + .expect_err("first recovery fails"); + assert_eq!( + error, + AcpSessionScopeRegistrationError::Recovery("load failed".to_string()) + ); + + writer + .ensure_session_scope("acp-1", "/tmp/ws", None, None) + .await + .expect("recovery retry"); + assert_eq!(*persister.load_calls.lock().expect("load calls"), 2); + } + + #[tokio::test] + async fn concurrent_first_scope_registration_waits_for_recovery() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + persister.block_next_load.store(true, Ordering::SeqCst); + let writer = AcpDurableProjectionWriter::new(queue, persister.clone()); + + let first_writer = writer.clone(); + let first = tokio::spawn(async move { + first_writer + .ensure_session_scope("acp-1", "/tmp/ws", None, None) + .await + }); + tokio::time::timeout( + std::time::Duration::from_secs(1), + persister.load_started.notified(), + ) + .await + .expect("first recovery started"); + + let second_writer = writer.clone(); + let second = tokio::spawn(async move { + second_writer + .ensure_session_scope("acp-1", "/tmp/ws", None, None) + .await + }); + for _ in 0..3 { + tokio::task::yield_now().await; + } + assert!(!second.is_finished()); + + persister.release_load.notify_one(); + first + .await + .expect("first task") + .expect("first registration"); + second + .await + .expect("second task") + .expect("second registration"); + assert_eq!(*persister.load_calls.lock().expect("load calls"), 1); + } + + #[tokio::test] + async fn persist_failure_marks_history_snapshot_required() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue, persister.clone()); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::SessionCreated { + session_id: "acp-1".to_string(), + session_name: "ACP".to_string(), + agent_type: "acp:gemini".to_string(), + workspace_path: Some("/tmp/ws".to_string()), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + }, + )) + .await + .expect("session"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::DialogTurnStarted { + session_id: "acp-1".to_string(), + turn_id: "turn-fail".to_string(), + turn_index: 0, + user_input: "hello".to_string(), + original_user_input: None, + user_message_metadata: None, + }, + )) + .await + .expect("start"); + *persister.fail_next.lock().expect("fail") = true; + let error = writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::DialogTurnCompleted { + session_id: "acp-1".to_string(), + turn_id: "turn-fail".to_string(), + total_rounds: 0, + total_tools: 0, + duration_ms: 1, + partial_recovery_reason: None, + success: Some(true), + finish_reason: None, + has_final_response: None, + }, + )) + .await + .expect_err("persist must fail loud"); + assert!(error.to_string().contains("disk full")); + assert!(writer.has_draft("acp-1", "turn-fail")); + assert_eq!( + persister.unreadable.lock().expect("unreadable").as_slice(), + ["acp-1"] + ); + + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::DialogTurnCompleted { + session_id: "acp-1".to_string(), + turn_id: "turn-fail".to_string(), + total_rounds: 0, + total_tools: 0, + duration_ms: 1, + partial_recovery_reason: None, + success: Some(true), + finish_reason: None, + has_final_response: None, + }, + )) + .await + .expect("retry after persist failure"); + assert!(!writer.has_draft("acp-1", "turn-fail")); + assert_eq!( + persister.turns.lock().expect("turns")[0].status, + TurnStatus::Completed + ); + } + + #[tokio::test] + async fn restart_recovers_in_progress_turn_as_interrupted() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue.clone(), persister.clone()); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::SessionCreated { + session_id: "acp-1".to_string(), + session_name: "ACP".to_string(), + agent_type: "acp:gemini".to_string(), + workspace_path: Some("/tmp/ws".to_string()), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + }, + )) + .await + .expect("session"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::DialogTurnStarted { + session_id: "acp-1".to_string(), + turn_id: "turn-open".to_string(), + turn_index: 0, + user_input: "hello".to_string(), + original_user_input: None, + user_message_metadata: None, + }, + )) + .await + .expect("start"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::ModelRoundStarted { + session_id: "acp-1".to_string(), + turn_id: "turn-open".to_string(), + round_id: "round-1".to_string(), + round_group_id: None, + round_index: 0, + identity: ModelRoundIdentity::External { + provider: "acp".to_string(), + client_id: "gemini".to_string(), + model_id: None, + display_name: None, + }, + render_hints: None, + }, + )) + .await + .expect("round"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::TextChunk { + session_id: "acp-1".to_string(), + turn_id: "turn-open".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "partial".to_string(), + }, + )) + .await + .expect("text"); + // Structural tool boundary forces a checkpoint so the partial text is on disk. + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::ToolEvent { + session_id: "acp-1".to_string(), + turn_id: "turn-open".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Started { + identity: ToolEventIdentity::direct("tool-1", "read"), + params: serde_json::json!({"path": "a.rs"}), + timeout_seconds: None, + }, + }, + )) + .await + .expect("tool"); + assert_eq!( + persister.turns.lock().expect("turns")[0].status, + TurnStatus::InProgress + ); + drop(writer); + + let recovered = AcpDurableProjectionWriter::new(queue.clone(), persister.clone()); + recovered + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::SessionCreated { + session_id: "acp-1".to_string(), + session_name: "ACP".to_string(), + agent_type: "acp:gemini".to_string(), + workspace_path: Some("/tmp/ws".to_string()), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + }, + )) + .await + .expect("recover"); + let turns = persister.turns.lock().expect("turns"); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].status, TurnStatus::Cancelled); + assert_eq!(turns[0].user_message.content, "hello"); + assert_eq!(turns[0].model_rounds[0].text_items[0].content, "partial"); + assert_eq!( + turns[0].recovery.as_ref().map(|recovery| recovery.status), + Some(DialogTurnRecoveryStatus::Interrupted) + ); + drop(turns); + + let batch = queue.dequeue_configured_batch().await; + assert!(batch.iter().any(|envelope| matches!( + &envelope.event, + AgenticEvent::SessionHistoryChanged { + session_id, + settled_turn_id: Some(turn_id), + } if session_id == "acp-1" && turn_id == "turn-open" + ))); + } + + #[tokio::test] + async fn flush_interrupted_persists_open_drafts() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue.clone(), persister.clone()); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::SessionCreated { + session_id: "acp-1".to_string(), + session_name: "ACP".to_string(), + agent_type: "acp:gemini".to_string(), + workspace_path: Some("/tmp/ws".to_string()), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + }, + )) + .await + .expect("session"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::DialogTurnStarted { + session_id: "acp-1".to_string(), + turn_id: "turn-open".to_string(), + turn_index: 0, + user_input: "hello".to_string(), + original_user_input: None, + user_message_metadata: None, + }, + )) + .await + .expect("start"); + writer.flush_interrupted().await.expect("flush"); + assert!(!writer.has_draft("acp-1", "turn-open")); + let turns = persister.turns.lock().expect("turns"); + assert_eq!(turns[0].status, TurnStatus::Cancelled); + assert_eq!( + turns[0].recovery.as_ref().map(|recovery| recovery.status), + Some(DialogTurnRecoveryStatus::Interrupted) + ); + } + + async fn open_session_and_turn( + writer: &AcpDurableProjectionWriter>, + turn_id: &str, + ) { + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::SessionCreated { + session_id: "acp-1".to_string(), + session_name: "ACP".to_string(), + agent_type: "acp:gemini".to_string(), + workspace_path: Some("/tmp/ws".to_string()), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + }, + )) + .await + .expect("session"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::DialogTurnStarted { + session_id: "acp-1".to_string(), + turn_id: turn_id.to_string(), + turn_index: 0, + user_input: "hello".to_string(), + original_user_input: None, + user_message_metadata: None, + }, + )) + .await + .expect("start"); + } + + #[tokio::test] + async fn turn_index_is_resolved_once_per_turn() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue, persister.clone()); + drive_completed_turn(&writer).await; + assert_eq!(*persister.index_lookups.lock().expect("lookups"), 1); + } + + #[tokio::test] + async fn streaming_text_chunks_do_not_checkpoint_every_token() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue, persister.clone()); + open_session_and_turn(&writer, "turn-stream").await; + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::ModelRoundStarted { + session_id: "acp-1".to_string(), + turn_id: "turn-stream".to_string(), + round_id: "round-1".to_string(), + round_group_id: None, + round_index: 0, + identity: ModelRoundIdentity::External { + provider: "acp".to_string(), + client_id: "gemini".to_string(), + model_id: None, + display_name: None, + }, + render_hints: None, + }, + )) + .await + .expect("round"); + let after_structural = *persister.persist_calls.lock().expect("persist"); + for _ in 0..20 { + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::TextChunk { + session_id: "acp-1".to_string(), + turn_id: "turn-stream".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "x".to_string(), + }, + )) + .await + .expect("text"); + } + assert_eq!( + *persister.persist_calls.lock().expect("persist"), + after_structural, + "small streaming chunks must not write every token" + ); + assert_eq!(*persister.index_lookups.lock().expect("lookups"), 1); + } + + #[tokio::test] + async fn streaming_byte_threshold_forces_checkpoint() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue, persister.clone()); + open_session_and_turn(&writer, "turn-bytes").await; + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::ModelRoundStarted { + session_id: "acp-1".to_string(), + turn_id: "turn-bytes".to_string(), + round_id: "round-1".to_string(), + round_group_id: None, + round_index: 0, + identity: ModelRoundIdentity::External { + provider: "acp".to_string(), + client_id: "gemini".to_string(), + model_id: None, + display_name: None, + }, + render_hints: None, + }, + )) + .await + .expect("round"); + let after_structural = *persister.persist_calls.lock().expect("persist"); + let bulky = "a".repeat(super::STREAMING_CHECKPOINT_MIN_BYTES); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::TextChunk { + session_id: "acp-1".to_string(), + turn_id: "turn-bytes".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: bulky, + }, + )) + .await + .expect("bulky text"); + assert_eq!( + *persister.persist_calls.lock().expect("persist"), + after_structural + 1 + ); + assert!( + persister.turns.lock().expect("turns")[0] + .model_rounds + .last() + .unwrap() + .text_items + .last() + .unwrap() + .content + .len() + >= super::STREAMING_CHECKPOINT_MIN_BYTES + ); + } + + #[tokio::test] + async fn streaming_time_threshold_forces_checkpoint() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue, persister.clone()); + open_session_and_turn(&writer, "turn-time").await; + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::ModelRoundStarted { + session_id: "acp-1".to_string(), + turn_id: "turn-time".to_string(), + round_id: "round-1".to_string(), + round_group_id: None, + round_index: 0, + identity: ModelRoundIdentity::External { + provider: "acp".to_string(), + client_id: "gemini".to_string(), + model_id: None, + display_name: None, + }, + render_hints: None, + }, + )) + .await + .expect("round"); + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::TextChunk { + session_id: "acp-1".to_string(), + turn_id: "turn-time".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "a".to_string(), + }, + )) + .await + .expect("early text"); + let after_early = *persister.persist_calls.lock().expect("persist"); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::TextChunk { + session_id: "acp-1".to_string(), + turn_id: "turn-time".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "b".to_string(), + }, + )) + .await + .expect("late text"); + assert_eq!( + *persister.persist_calls.lock().expect("persist"), + after_early + 1 + ); + } + + #[tokio::test] + async fn mid_turn_checkpoint_failure_does_not_mark_history_unreadable() { + let queue = Arc::new(EventQueue::new(Default::default())); + let persister = Arc::new(RecordingPersister::default()); + let writer = AcpDurableProjectionWriter::new(queue, persister.clone()); + open_session_and_turn(&writer, "turn-ckpt-fail").await; + *persister.fail_next.lock().expect("fail") = true; + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::ModelRoundStarted { + session_id: "acp-1".to_string(), + turn_id: "turn-ckpt-fail".to_string(), + round_id: "round-1".to_string(), + round_group_id: None, + round_index: 0, + identity: ModelRoundIdentity::External { + provider: "acp".to_string(), + client_id: "gemini".to_string(), + model_id: None, + display_name: None, + }, + render_hints: None, + }, + )) + .await + .expect("checkpoint failure must not fail the subscriber"); + assert!(persister.unreadable.lock().expect("unreadable").is_empty()); + assert!(writer.has_draft("acp-1", "turn-ckpt-fail")); + + // Sustained failure must not retry a doomed write on every stream chunk: + // throttle advances even when the checkpoint itself failed. + *persister.fail_next.lock().expect("fail") = true; + let before = *persister.persist_calls.lock().expect("persist"); + for _ in 0..10 { + writer + .on_envelope(&envelope( + AgenticEventOrigin::ExternalAcp, + AgenticEvent::TextChunk { + session_id: "acp-1".to_string(), + turn_id: "turn-ckpt-fail".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "x".to_string(), + }, + )) + .await + .expect("stream"); + } + assert_eq!( + *persister.persist_calls.lock().expect("persist"), + before, + "failed checkpoint must advance throttle so tiny chunks do not re-hit disk" + ); + } +} diff --git a/src/apps/desktop/src/runtime/acp_request_idempotency.rs b/src/apps/desktop/src/runtime/acp_request_idempotency.rs new file mode 100644 index 0000000000..91c621eebe --- /dev/null +++ b/src/apps/desktop/src/runtime/acp_request_idempotency.rs @@ -0,0 +1,103 @@ +//! Idempotent request-id claim helpers for Desktop ACP remote control. +//! +//! Callers mint a candidate value, then claim under a single map lock. If another +//! caller already owns the key, the existing value is returned and no side +//! effects should run. + +use std::collections::hash_map::Entry; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum IdempotentClaim { + /// This caller inserted `candidate` and owns the side effects. + Claimed(T), + /// Another caller already claimed this request id. + Existing(T), +} + +/// Insert `candidate` if `key` is vacant; otherwise return the existing value. +/// +/// Must be called while holding the map mutex for the whole claim. +pub(crate) fn claim_idempotent_value( + map: &mut HashMap, + key: String, + candidate: T, +) -> IdempotentClaim { + match map.entry(key) { + Entry::Occupied(entry) => IdempotentClaim::Existing(entry.get().clone()), + Entry::Vacant(entry) => { + entry.insert(candidate.clone()); + IdempotentClaim::Claimed(candidate) + } + } +} + +pub(crate) fn request_idempotency_key(session_id: &str, request_id: &str) -> String { + format!("{session_id}\0{request_id}") +} + +pub(crate) fn clear_session_idempotency_keys(map: &mut HashMap, session_id: &str) { + let prefix = format!("{session_id}\0"); + map.retain(|key, _| !key.starts_with(&prefix)); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + use std::thread; + + #[test] + fn first_claim_wins_and_second_sees_existing() { + let mut map = HashMap::new(); + let first = claim_idempotent_value(&mut map, "s\0r".to_string(), "turn-a".to_string()); + let second = claim_idempotent_value(&mut map, "s\0r".to_string(), "turn-b".to_string()); + assert_eq!(first, IdempotentClaim::Claimed("turn-a".to_string())); + assert_eq!(second, IdempotentClaim::Existing("turn-a".to_string())); + assert_eq!(map.get("s\0r").map(String::as_str), Some("turn-a")); + } + + #[test] + fn concurrent_claims_produce_one_owner() { + let map = Arc::new(Mutex::new(HashMap::::new())); + let key = "acp-1\0req-1".to_string(); + let mut handles = Vec::new(); + for i in 0..32 { + let map = map.clone(); + let key = key.clone(); + handles.push(thread::spawn(move || { + let candidate = format!("turn-{i}"); + let mut guard = map.lock().expect("map"); + claim_idempotent_value(&mut guard, key, candidate) + })); + } + let mut claimed = 0; + let mut existing = 0; + let mut winners = Vec::new(); + for handle in handles { + match handle.join().expect("join") { + IdempotentClaim::Claimed(value) => { + claimed += 1; + winners.push(value); + } + IdempotentClaim::Existing(_) => existing += 1, + } + } + assert_eq!(claimed, 1); + assert_eq!(existing, 31); + assert_eq!(winners.len(), 1); + assert_eq!(map.lock().expect("map").get(&key), Some(&winners[0])); + } + + #[test] + fn clear_session_removes_only_matching_prefix() { + let mut map = HashMap::new(); + map.insert("acp-1\0a".to_string(), 1); + map.insert("acp-1\0b".to_string(), 2); + map.insert("acp-2\0a".to_string(), 3); + clear_session_idempotency_keys(&mut map, "acp-1"); + assert!(map.get("acp-1\0a").is_none()); + assert!(map.get("acp-1\0b").is_none()); + assert_eq!(map.get("acp-2\0a"), Some(&3)); + } +} diff --git a/src/apps/desktop/src/runtime/mod.rs b/src/apps/desktop/src/runtime/mod.rs index edc899f395..6eae48f147 100644 --- a/src/apps/desktop/src/runtime/mod.rs +++ b/src/apps/desktop/src/runtime/mod.rs @@ -9,9 +9,23 @@ use bitfun_core::service::token_usage::TokenUsageService; use bitfun_core::service::workspace::WorkspaceService; use tokio::sync::RwLock; +mod acp_event_publisher; +mod acp_permission_observer; +mod acp_projection_writer; +mod acp_request_idempotency; +mod remote_acp_control_host; mod session_application; mod session_host_effects; +pub(crate) use acp_event_publisher::{ + acp_dialog_turn_started_event, acp_session_created_event, AcpEventPublisher, AcpTurnMapper, +}; +pub(crate) use acp_permission_observer::DesktopAcpPermissionObserver; +pub(crate) use acp_projection_writer::{ + flush_desktop_acp_writer_blocking, install_desktop_acp_writer, AcpDurableProjectionWriter, +}; +pub(crate) use remote_acp_control_host::DesktopRemoteAcpControlHost; + use session_host_effects::ProductionDesktopSessionHostEffects; pub(crate) use session_application::{ diff --git a/src/apps/desktop/src/runtime/remote_acp_control_host.rs b/src/apps/desktop/src/runtime/remote_acp_control_host.rs new file mode 100644 index 0000000000..69817b94d0 --- /dev/null +++ b/src/apps/desktop/src/runtime/remote_acp_control_host.rs @@ -0,0 +1,873 @@ +//! Desktop-owned ACP remote-control host for Remote Connect. +//! +//! Translates `RemoteCommand::Acp*` into `AcpClientService` calls and publishes +//! observation events through the existing `AcpEventPublisher`. The phone never +//! receives ACP process handles or native tool ids. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use bitfun_acp::client::{ + is_acp_permission_id, AcpClientService, AcpSessionConfigValue, + SetAcpSessionConfigOptionRequest, SubmitAcpPermissionResponseRequest, +}; +use bitfun_core::agentic::coordination::get_global_coordinator; +use bitfun_core::service::remote_connect::remote_server::get_or_init_global_dispatcher; +use bitfun_core::service::remote_connect::resolve_remote_session_workspace_scope; +use bitfun_core::service::session::SESSION_PROVIDER_ACP; +use bitfun_core_types::SESSION_PROVIDER_METADATA_KEY; +use bitfun_services_integrations::remote_connect::{ + acp_permission_mailbox, acp_permission_now_ms, RemoteAcpCancelOutcome, RemoteAcpCancelRequest, + RemoteAcpCommandsOutcome, RemoteAcpControlError, RemoteAcpControlRuntimeHost, + RemoteAcpGetCommandsRequest, RemoteAcpGetOptionsRequest, RemoteAcpGetPlanRequest, + RemoteAcpOptionsOutcome, RemoteAcpPermissionRespondOutcome, RemoteAcpPermissionRespondRequest, + RemoteAcpPlanOutcome, RemoteAcpSendOutcome, RemoteAcpSendRequest, RemoteAcpSetOptionRequest, + RemoteRetryClassification, UNSUPPORTED_REMOTE_CAPABILITY, +}; +use tokio::sync::Mutex as AsyncMutex; +use uuid::Uuid; + +use super::acp_projection_writer::AcpSessionScopeRegistrationError; +use super::acp_request_idempotency::{ + claim_idempotent_value, clear_session_idempotency_keys, request_idempotency_key, + IdempotentClaim, +}; +use super::{ + acp_dialog_turn_started_event, AcpDurableProjectionWriter, AcpEventPublisher, AcpTurnMapper, + DesktopSessionApplication, +}; + +const ACP_CLIENT_ID_METADATA_KEY: &str = "acpClientId"; + +fn scope_registration_retry_classification( + error: &AcpSessionScopeRegistrationError, +) -> RemoteRetryClassification { + match error { + AcpSessionScopeRegistrationError::Conflict { .. } => RemoteRetryClassification::Terminal, + AcpSessionScopeRegistrationError::Recovery(_) => RemoteRetryClassification::Retryable, + } +} + +pub(crate) struct DesktopRemoteAcpControlHost { + service: Arc, + publisher: Arc, + projection_writer: Arc>, + /// Idempotent request_id → turn_id for AcpSendMessage retries. + send_by_request: Mutex>, + /// Idempotent request_id → cancel outcome for AcpCancelTurn retries. + cancel_by_request: Mutex>, + cancel_request_lock: AsyncMutex<()>, + /// Idempotent request_id → options snapshot for AcpSetOption retries. + set_option_by_request: Mutex>, + set_option_request_lock: AsyncMutex<()>, + /// Idempotent request_id → permission outcome for AcpPermissionRespond retries. + permission_by_request: Mutex>, + permission_request_lock: AsyncMutex<()>, +} + +impl DesktopRemoteAcpControlHost { + pub(crate) fn new( + service: Arc, + publisher: Arc, + projection_writer: Arc>, + ) -> Self { + Self { + service, + publisher, + projection_writer, + send_by_request: Mutex::new(HashMap::new()), + cancel_by_request: Mutex::new(HashMap::new()), + cancel_request_lock: AsyncMutex::new(()), + set_option_by_request: Mutex::new(HashMap::new()), + set_option_request_lock: AsyncMutex::new(()), + permission_by_request: Mutex::new(HashMap::new()), + permission_request_lock: AsyncMutex::new(()), + } + } + + pub(crate) fn clear_session_idempotency(&self, session_id: &str) { + clear_session_idempotency_keys( + &mut self.send_by_request.lock().expect("ACP send idempotency"), + session_id, + ); + clear_session_idempotency_keys( + &mut self + .cancel_by_request + .lock() + .expect("ACP cancel idempotency"), + session_id, + ); + clear_session_idempotency_keys( + &mut self + .set_option_by_request + .lock() + .expect("ACP set_option idempotency"), + session_id, + ); + clear_session_idempotency_keys( + &mut self + .permission_by_request + .lock() + .expect("ACP permission idempotency"), + session_id, + ); + } + + async fn resolve_session_context( + &self, + session_id: &str, + ) -> Result { + let workspace_scope = resolve_remote_session_workspace_scope(session_id) + .await + .ok_or_else(|| { + RemoteAcpControlError::terminal( + session_id, + None, + "acp_session_not_found", + format!("ACP session workspace scope was not found: {session_id}"), + ) + })?; + let coordinator = get_global_coordinator().ok_or_else(|| { + RemoteAcpControlError::terminal( + session_id, + None, + "acp_runtime_unavailable", + "Conversation coordinator is not available", + ) + })?; + let metadata = coordinator + .get_session_manager() + .load_session_metadata(&workspace_scope.session_storage_path, session_id) + .await + .map_err(|error| { + RemoteAcpControlError::terminal( + session_id, + None, + "acp_session_metadata_error", + error.to_string(), + ) + })? + .ok_or_else(|| { + RemoteAcpControlError::terminal( + session_id, + None, + "acp_session_not_found", + format!("ACP session metadata was not found: {session_id}"), + ) + })?; + + let provider = metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get(SESSION_PROVIDER_METADATA_KEY)) + .and_then(serde_json::Value::as_str); + if provider != Some(SESSION_PROVIDER_ACP) { + return Err(RemoteAcpControlError::terminal( + session_id, + None, + UNSUPPORTED_REMOTE_CAPABILITY, + format!("Session is not ACP-controlled: {session_id}"), + )); + } + + let client_id = metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get(ACP_CLIENT_ID_METADATA_KEY)) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .or_else(|| metadata.agent_type.strip_prefix("acp:").map(str::to_string)) + .unwrap_or_else(|| metadata.agent_type.clone()); + + Ok(ResolvedAcpSession { + client_id, + workspace_path: workspace_scope.workspace_path, + session_storage_path: workspace_scope.session_storage_path, + remote_connection_id: workspace_scope.remote_connection_id, + remote_ssh_host: workspace_scope.remote_ssh_host, + }) + } +} + +struct ResolvedAcpSession { + client_id: String, + workspace_path: String, + session_storage_path: PathBuf, + remote_connection_id: Option, + remote_ssh_host: Option, +} + +#[async_trait] +impl RemoteAcpControlRuntimeHost for DesktopRemoteAcpControlHost { + async fn send_message( + &self, + request: RemoteAcpSendRequest, + ) -> Result { + let resolved = self.resolve_session_context(&request.session_id).await?; + self.projection_writer + .ensure_session_scope( + &request.session_id, + &resolved.workspace_path, + resolved.remote_connection_id.as_deref(), + resolved.remote_ssh_host.as_deref(), + ) + .await + .map_err(|error| { + let retry = scope_registration_retry_classification(&error); + RemoteAcpControlError { + session_id: request.session_id.clone(), + request_id: request.request_id.clone(), + code: "acp_projection_scope_failed".to_string(), + message: error.to_string(), + retry, + } + })?; + // Subscribe before publishing the first ACP lifecycle event. Poll-created + // trackers are too late for a send that starts before the phone's first + // poll, and ACP sessions remain external projections rather than native + // SessionManager-owned sessions. + get_or_init_global_dispatcher().ensure_tracker(&request.session_id); + let candidate_turn_id = format!("acp-remote-{}", Uuid::new_v4()); + let turn_id = if let Some(request_id) = request.request_id.as_deref() { + let key = request_idempotency_key(&request.session_id, request_id); + let claim = { + let mut map = self.send_by_request.lock().expect("ACP send idempotency"); + claim_idempotent_value(&mut map, key, candidate_turn_id.clone()) + }; + match claim { + IdempotentClaim::Existing(existing) => { + return Ok(RemoteAcpSendOutcome { + session_id: request.session_id, + turn_id: existing, + request_id: request.request_id, + }); + } + IdempotentClaim::Claimed(claimed) => claimed, + } + } else { + candidate_turn_id + }; + + let service = self.service.clone(); + let publisher = self.publisher.clone(); + let session_id = request.session_id.clone(); + let content = request.content.clone(); + let client_id = resolved.client_id.clone(); + let workspace_path = resolved.workspace_path.clone(); + let session_storage_path = resolved.session_storage_path.clone(); + let remote_connection_id = resolved.remote_connection_id.clone(); + + publisher + .publish_turn_started(acp_dialog_turn_started_event( + session_id.clone(), + turn_id.clone(), + content.clone(), + None, + )) + .map_err(|error| { + // Claim happened before side effects; roll it back so a retry can + // re-claim and publish instead of returning a never-started turn. + if let Some(request_id) = request.request_id.as_deref() { + let key = request_idempotency_key(&request.session_id, request_id); + let mut map = self.send_by_request.lock().expect("ACP send idempotency"); + if map.get(&key).is_some_and(|owned| owned == &turn_id) { + map.remove(&key); + } + } + RemoteAcpControlError::terminal( + request.session_id.clone(), + request.request_id.clone(), + "acp_publish_failed", + error.to_string(), + ) + })?; + + let turn_id_for_task = turn_id.clone(); + let request_id = request.request_id.clone(); + tokio::spawn(async move { + let mut mapper = AcpTurnMapper::new( + session_id.clone(), + turn_id_for_task.clone(), + client_id.clone(), + ); + let result = service + .prompt_agent_stream( + &client_id, + content, + Some(workspace_path), + remote_connection_id, + session_id.clone(), + Some(session_storage_path), + None, + |event| { + let jobs = mapper.map(event)?; + publisher + .publish_jobs(jobs) + .map_err(bitfun_core::util::errors::BitFunError::service) + }, + ) + .await; + if let Err(error) = result { + let _ = publisher.publish_jobs(mapper.fail(error.to_string())); + log::error!( + "Remote ACP send failed: session_id={session_id}, request_id={request_id:?}, error={error}" + ); + } + }); + + Ok(RemoteAcpSendOutcome { + session_id: request.session_id, + turn_id, + request_id: request.request_id, + }) + } + + async fn cancel_turn( + &self, + request: RemoteAcpCancelRequest, + ) -> Result { + // Serialize claim-through-side-effect so concurrent retries cannot both + // enter the ACP service before the first outcome is cached. + let _request_guard = self.cancel_request_lock.lock().await; + if let Some(request_id) = request.request_id.as_deref() { + let key = request_idempotency_key(&request.session_id, request_id); + if let Some(outcome) = self + .cancel_by_request + .lock() + .expect("ACP cancel idempotency") + .get(&key) + .cloned() + { + return Ok(outcome); + } + } + + if let Some(requested_turn_id) = request.turn_id.as_deref() { + let tracker = get_or_init_global_dispatcher().ensure_tracker(&request.session_id); + if let Some(active_turn) = tracker.snapshot_active_turn() { + if active_turn.turn_id != requested_turn_id { + return Err(RemoteAcpControlError { + session_id: request.session_id.clone(), + request_id: request.request_id.clone(), + code: "acp_turn_stale".to_string(), + message: format!( + "ACP active turn changed before cancellation: requested={}, active={}", + requested_turn_id, active_turn.turn_id + ), + retry: RemoteRetryClassification::Stale, + }); + } + } + } + + let cancelled = self + .service + .cancel_bitfun_session(&request.session_id) + .await + .map_err(|error| RemoteAcpControlError { + session_id: request.session_id.clone(), + request_id: request.request_id.clone(), + code: "acp_cancel_failed".to_string(), + message: error.to_string(), + retry: RemoteRetryClassification::Retryable, + })?; + if !cancelled { + return Err(RemoteAcpControlError { + session_id: request.session_id.clone(), + request_id: request.request_id.clone(), + code: "acp_turn_stale".to_string(), + message: format!( + "No active ACP turn to cancel for session {}", + request.session_id + ), + retry: RemoteRetryClassification::Stale, + }); + } + let outcome = RemoteAcpCancelOutcome { + session_id: request.session_id.clone(), + turn_id: request.turn_id, + request_id: request.request_id.clone(), + }; + if let Some(request_id) = request.request_id.as_deref() { + let key = request_idempotency_key(&outcome.session_id, request_id); + let claim = { + let mut map = self + .cancel_by_request + .lock() + .expect("ACP cancel idempotency"); + claim_idempotent_value(&mut map, key, outcome.clone()) + }; + if let IdempotentClaim::Existing(existing) = claim { + return Ok(existing); + } + } + Ok(outcome) + } + + async fn get_options( + &self, + request: RemoteAcpGetOptionsRequest, + ) -> Result { + let resolved = self.resolve_session_context(&request.session_id).await?; + let options = self + .service + .get_session_options( + &resolved.client_id, + Some(resolved.workspace_path), + resolved.remote_connection_id, + Some(resolved.session_storage_path), + request.session_id.clone(), + ) + .await + .map_err(|error| { + RemoteAcpControlError::terminal( + request.session_id.clone(), + request.request_id.clone(), + "acp_options_failed", + error.to_string(), + ) + })?; + Ok(RemoteAcpOptionsOutcome { + session_id: request.session_id, + request_id: request.request_id, + options: serde_json::to_value(options).unwrap_or(serde_json::Value::Null), + }) + } + + async fn set_option( + &self, + request: RemoteAcpSetOptionRequest, + ) -> Result { + // Keep the request-id lookup and ACP mutation in one async critical + // section so a concurrent retry observes the first cached result. + let _request_guard = self.set_option_request_lock.lock().await; + if let Some(request_id) = request.request_id.as_deref() { + let key = request_idempotency_key(&request.session_id, request_id); + if let Some(outcome) = self + .set_option_by_request + .lock() + .expect("ACP set_option idempotency") + .get(&key) + .cloned() + { + return Ok(outcome); + } + } + + let resolved = self.resolve_session_context(&request.session_id).await?; + let value = parse_acp_config_value(&request.value).map_err(|message| { + RemoteAcpControlError::terminal( + request.session_id.clone(), + request.request_id.clone(), + "acp_invalid_option_value", + message, + ) + })?; + let options = self + .service + .set_session_config_option( + SetAcpSessionConfigOptionRequest { + client_id: resolved.client_id, + session_id: request.session_id.clone(), + workspace_path: Some(resolved.workspace_path), + remote_connection_id: resolved.remote_connection_id, + remote_ssh_host: resolved.remote_ssh_host, + config_id: request.config_id, + value, + }, + Some(resolved.session_storage_path), + ) + .await + .map_err(|error| { + RemoteAcpControlError::terminal( + request.session_id.clone(), + request.request_id.clone(), + "acp_set_option_failed", + error.to_string(), + ) + })?; + let outcome = RemoteAcpOptionsOutcome { + session_id: request.session_id, + request_id: request.request_id.clone(), + options: serde_json::to_value(options).unwrap_or(serde_json::Value::Null), + }; + if let Some(request_id) = request.request_id.as_deref() { + let key = request_idempotency_key(&outcome.session_id, request_id); + let claim = { + let mut map = self + .set_option_by_request + .lock() + .expect("ACP set_option idempotency"); + claim_idempotent_value(&mut map, key, outcome.clone()) + }; + if let IdempotentClaim::Existing(existing) = claim { + return Ok(existing); + } + } + Ok(outcome) + } + + async fn get_commands( + &self, + request: RemoteAcpGetCommandsRequest, + ) -> Result { + let resolved = self.resolve_session_context(&request.session_id).await?; + let (commands, version) = self + .service + .get_session_commands( + &resolved.client_id, + Some(resolved.workspace_path), + resolved.remote_connection_id, + Some(resolved.session_storage_path), + request.session_id.clone(), + ) + .await + .map_err(|error| { + RemoteAcpControlError::terminal( + request.session_id.clone(), + request.request_id.clone(), + "acp_commands_failed", + error.to_string(), + ) + })?; + Ok(RemoteAcpCommandsOutcome { + session_id: request.session_id, + request_id: request.request_id, + commands: serde_json::to_value(commands).unwrap_or_else(|_| serde_json::json!([])), + version, + }) + } + + async fn get_plan( + &self, + request: RemoteAcpGetPlanRequest, + ) -> Result { + let resolved = self.resolve_session_context(&request.session_id).await?; + let (entries, version) = self + .service + .get_session_plan( + &resolved.client_id, + Some(resolved.workspace_path), + resolved.remote_connection_id, + Some(resolved.session_storage_path), + request.session_id.clone(), + ) + .await + .map_err(|error| { + RemoteAcpControlError::terminal( + request.session_id.clone(), + request.request_id.clone(), + "acp_plan_failed", + error.to_string(), + ) + })?; + Ok(RemoteAcpPlanOutcome { + session_id: request.session_id, + request_id: request.request_id, + entries: serde_json::to_value(entries).unwrap_or_else(|_| serde_json::json!([])), + version, + }) + } + + async fn permission_respond( + &self, + request: RemoteAcpPermissionRespondRequest, + ) -> Result { + // The permission may disappear from the mailbox as soon as the first + // response resolves. Serialize through caching so a concurrent retry + // returns that first outcome instead of being misclassified as stale. + let _request_guard = self.permission_request_lock.lock().await; + if let Some(request_id) = request.request_id.as_deref() { + let key = request_idempotency_key(&request.session_id, request_id); + if let Some(outcome) = self + .permission_by_request + .lock() + .expect("ACP permission idempotency") + .get(&key) + .cloned() + { + return Ok(outcome); + } + } + + let _ = self.resolve_session_context(&request.session_id).await?; + let mailbox_entry = acp_permission_mailbox() + .and_then(|mailbox| mailbox.get(&request.permission_id)) + .ok_or_else(|| RemoteAcpControlError { + session_id: request.session_id.clone(), + request_id: request.request_id.clone(), + code: "acp_permission_stale".to_string(), + message: format!( + "ACP permission already resolved or expired: {}", + request.permission_id + ), + retry: RemoteRetryClassification::Stale, + })?; + if mailbox_entry.session_id != request.session_id { + return Err(RemoteAcpControlError::terminal( + request.session_id.clone(), + request.request_id.clone(), + "acp_permission_session_mismatch", + format!( + "ACP permission belongs to another session: permission_id={}", + request.permission_id + ), + )); + } + if mailbox_entry.expires_at_ms > 0 && mailbox_entry.expires_at_ms <= acp_permission_now_ms() + { + return Err(RemoteAcpControlError { + session_id: request.session_id.clone(), + request_id: request.request_id.clone(), + code: "acp_permission_stale".to_string(), + message: format!("ACP permission expired: {}", request.permission_id), + retry: RemoteRetryClassification::Stale, + }); + } + if !permission_options_contain(&mailbox_entry.options, &request.option_id) { + return Err(RemoteAcpControlError::terminal( + request.session_id.clone(), + request.request_id.clone(), + "acp_invalid_permission_option", + format!( + "ACP permission option is not pending: permission_id={}, option_id={}", + request.permission_id, request.option_id + ), + )); + } + if !self.service.has_pending_permission(&request.permission_id) { + return Err(RemoteAcpControlError { + session_id: request.session_id.clone(), + request_id: request.request_id.clone(), + code: "acp_permission_stale".to_string(), + message: format!( + "ACP permission already resolved or expired: {}", + request.permission_id + ), + retry: RemoteRetryClassification::Stale, + }); + } + let response = self + .service + .submit_permission_response(SubmitAcpPermissionResponseRequest { + permission_id: request.permission_id.clone(), + approve: true, + option_id: Some(request.option_id), + }) + .await + .map_err(|error| { + RemoteAcpControlError::terminal( + request.session_id.clone(), + request.request_id.clone(), + "acp_permission_respond_failed", + error.to_string(), + ) + })?; + let outcome = RemoteAcpPermissionRespondOutcome { + session_id: request.session_id, + permission_id: response.permission_id, + request_id: request.request_id.clone(), + resolved: response.resolved, + }; + if let Some(request_id) = request.request_id.as_deref() { + let key = request_idempotency_key(&outcome.session_id, request_id); + self.permission_by_request + .lock() + .expect("ACP permission idempotency") + .insert(key, outcome.clone()); + } + Ok(outcome) + } + + async fn is_acp_session(&self, session_id: &str) -> bool { + self.resolve_session_context(session_id).await.is_ok() + } + + async fn is_acp_permission_id(&self, tool_id: &str) -> bool { + is_acp_permission_id(tool_id) || self.service.has_pending_permission(tool_id) + } + + fn clear_session_idempotency(&self, session_id: &str) { + DesktopRemoteAcpControlHost::clear_session_idempotency(self, session_id); + } +} + +fn permission_options_contain(options: &serde_json::Value, option_id: &str) -> bool { + options.as_array().is_some_and(|entries| { + entries.iter().any(|entry| { + entry + .get("optionId") + .or_else(|| entry.get("option_id")) + .or_else(|| entry.get("id")) + .and_then(serde_json::Value::as_str) + == Some(option_id) + }) + }) +} + +fn parse_acp_config_value(value: &serde_json::Value) -> Result { + if let Some(object) = value.as_object() { + match object.get("type").and_then(|v| v.as_str()) { + Some("select") => { + let select = object + .get("value") + .and_then(|v| v.as_str()) + .ok_or_else(|| "select option requires string value".to_string())?; + return Ok(AcpSessionConfigValue::Select { + value: select.to_string(), + }); + } + Some("boolean") => { + let boolean = object + .get("value") + .and_then(|v| v.as_bool()) + .ok_or_else(|| "boolean option requires bool value".to_string())?; + return Ok(AcpSessionConfigValue::Boolean { value: boolean }); + } + _ => {} + } + if let Some(select) = object.get("value").and_then(|v| v.as_str()) { + return Ok(AcpSessionConfigValue::Select { + value: select.to_string(), + }); + } + if let Some(boolean) = object.get("value").and_then(|v| v.as_bool()) { + return Ok(AcpSessionConfigValue::Boolean { value: boolean }); + } + } + if let Some(select) = value.as_str() { + return Ok(AcpSessionConfigValue::Select { + value: select.to_string(), + }); + } + if let Some(boolean) = value.as_bool() { + return Ok(AcpSessionConfigValue::Boolean { value: boolean }); + } + Err(format!("Unsupported ACP option value: {value}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn method_body<'a>(source: &'a str, start: &str, end: &str) -> &'a str { + source + .split(start) + .nth(1) + .and_then(|source| source.split(end).next()) + .expect("reviewed remote ACP host method") + } + + #[test] + fn scope_registration_errors_have_stable_retry_semantics() { + assert_eq!( + scope_registration_retry_classification(&AcpSessionScopeRegistrationError::Conflict { + session_id: "acp-1".to_string(), + }), + RemoteRetryClassification::Terminal + ); + assert_eq!( + scope_registration_retry_classification(&AcpSessionScopeRegistrationError::Recovery( + "load failed".to_string() + )), + RemoteRetryClassification::Retryable + ); + } + + #[test] + fn send_registers_projection_scope_before_claiming_or_publishing() { + let source = include_str!("remote_acp_control_host.rs"); + let send = method_body(source, "async fn send_message", "async fn cancel_turn"); + let ensure_scope = send + .find(".ensure_session_scope(") + .expect("projection scope registration"); + let claim = send + .find("claim_idempotent_value") + .expect("request-id claim"); + let publish = send + .find(".publish_turn_started(") + .expect("turn-start publication"); + + assert!(ensure_scope < claim); + assert!(claim < publish); + } + + #[test] + fn session_aware_acp_calls_preserve_resolved_remote_scope() { + let source = include_str!("remote_acp_control_host.rs"); + let send = method_body(source, "async fn send_message", "async fn cancel_turn"); + assert!(send.contains("Some(workspace_path)")); + assert!(send.contains("remote_connection_id")); + assert!(send.contains("Some(session_storage_path)")); + + for (start, end) in [ + ("async fn get_options", "async fn set_option"), + ("async fn get_commands", "async fn get_plan"), + ("async fn get_plan", "async fn permission_respond"), + ] { + let body = method_body(source, start, end); + assert!(body.contains("Some(resolved.workspace_path)")); + assert!(body.contains("resolved.remote_connection_id")); + assert!(body.contains("Some(resolved.session_storage_path)")); + } + + let set_option = method_body(source, "async fn set_option", "async fn get_commands"); + assert!(set_option.contains("workspace_path: Some(resolved.workspace_path)")); + assert!(set_option.contains("remote_connection_id: resolved.remote_connection_id")); + assert!(set_option.contains("remote_ssh_host: resolved.remote_ssh_host")); + assert!(set_option.contains("Some(resolved.session_storage_path)")); + } + + #[test] + fn mutating_remote_commands_serialize_lookup_through_cached_outcome() { + let source = include_str!("remote_acp_control_host.rs"); + for (start, end, lock, side_effect) in [ + ( + "async fn cancel_turn", + "async fn get_options", + "cancel_request_lock.lock().await", + ".cancel_bitfun_session(", + ), + ( + "async fn set_option", + "async fn get_commands", + "set_option_request_lock.lock().await", + ".set_session_config_option(", + ), + ( + "async fn permission_respond", + "async fn is_acp_session", + "permission_request_lock.lock().await", + ".submit_permission_response(", + ), + ] { + let body = method_body(source, start, end); + let lock_index = body.find(lock).expect("operation lock"); + let cache_index = body + .find("request_idempotency_key") + .expect("request-id cache lookup"); + let side_effect_index = body.find(side_effect).expect("ACP side effect"); + assert!(lock_index < cache_index); + assert!(cache_index < side_effect_index); + } + } + + #[test] + fn permission_option_membership_accepts_protocol_aliases_only() { + let options = serde_json::json!([ + { "optionId": "allow-once" }, + { "option_id": "reject-once" }, + { "id": "allow-always" } + ]); + assert!(permission_options_contain(&options, "allow-once")); + assert!(permission_options_contain(&options, "reject-once")); + assert!(permission_options_contain(&options, "allow-always")); + assert!(!permission_options_contain(&options, "native-tool-1")); + assert!(!permission_options_contain( + &serde_json::json!({ "optionId": "allow-once" }), + "allow-once" + )); + } +} diff --git a/src/apps/desktop/src/runtime/session_host_effects.rs b/src/apps/desktop/src/runtime/session_host_effects.rs index ccf6960161..48548870f1 100644 --- a/src/apps/desktop/src/runtime/session_host_effects.rs +++ b/src/apps/desktop/src/runtime/session_host_effects.rs @@ -28,5 +28,11 @@ impl DesktopSessionHostEffects for ProductionDesktopSessionHostEffects { fn notify_session_deleted(&self, session_id: &str) { crate::api::remote_connect_api::notify_session_deleted(session_id); + bitfun_core::service::remote_connect::clear_remote_acp_control_session(session_id); + if let Some(mailbox) = + bitfun_services_integrations::remote_connect::acp_permission_mailbox() + { + mailbox.clear_session(session_id); + } } } diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 41bb644f54..f033d09aca 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -4162,8 +4162,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet round_id: round_id.clone(), round_group_id: None, round_index: 0, - model_config_id: String::new(), - effective_model_name: String::new(), + identity: bitfun_events::ModelRoundIdentity::Native { + model_config_id: String::new(), + effective_model_name: String::new(), + }, + render_hints: None, }) .await; self.emit_event(AgenticEvent::ToolEvent { diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index eda9c97d2d..e0e0604165 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -355,8 +355,11 @@ impl RoundExecutor { round_id: round_id.clone(), round_group_id: context.round_group_id.clone(), round_index: context.round_number, - model_config_id: context.model_config_id.clone(), - effective_model_name: context.effective_model_name.clone(), + identity: bitfun_events::ModelRoundIdentity::Native { + model_config_id: context.model_config_id.clone(), + effective_model_name: context.effective_model_name.clone(), + }, + render_hints: None, }, EventPriority::High, ) diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 6e72465ebc..ddf5f82b49 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -3745,6 +3745,19 @@ mod tests { .len(), 1 ); + assert!( + compatibility + .is_externally_projected_session(&storage_path, acp_session_id) + .await + .expect("projected kind"), + "persisting an ACP turn must not rewrite the session into a native Runtime session" + ); + assert!( + !compatibility + .is_session_loaded_in_memory(acp_session_id) + .expect("memory occupancy"), + "ACP durable projection must not load the session into SessionManager" + ); let runtime_session_id = "runtime-owned"; persistence diff --git a/src/crates/assembly/core/src/service/cron/subscriber.rs b/src/crates/assembly/core/src/service/cron/subscriber.rs index 102a04bd01..0e47cd6a23 100644 --- a/src/crates/assembly/core/src/service/cron/subscriber.rs +++ b/src/crates/assembly/core/src/service/cron/subscriber.rs @@ -3,6 +3,7 @@ use super::service::CronService; use crate::agentic::events::{AgenticEvent, EventSubscriber}; use bitfun_agent_runtime::event_bus::{EventBusError, EventSubscriberResult}; +use bitfun_events::{AgenticEventEnvelope, AgenticEventOrigin}; use log::error; use std::sync::Arc; @@ -18,6 +19,13 @@ impl CronEventSubscriber { #[async_trait::async_trait] impl EventSubscriber for CronEventSubscriber { + async fn on_envelope(&self, envelope: &AgenticEventEnvelope) -> EventSubscriberResult { + if envelope.origin != AgenticEventOrigin::NativeRuntime { + return Ok(()); + } + self.on_event(&envelope.event).await + } + async fn on_event(&self, event: &AgenticEvent) -> EventSubscriberResult { let result = match event { AgenticEvent::DialogTurnStarted { turn_id, .. } => { diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs index 8d49d07081..07c46ad020 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs @@ -21,12 +21,153 @@ use std::sync::{Arc, OnceLock}; pub use super::locale::{current_bot_language, BotLanguage}; use super::locale::{fmt_count, strings_for, BotStrings}; use super::menu::{MenuItem, MenuView}; +use bitfun_core_types::SESSION_PROVIDER_METADATA_KEY; +use bitfun_services_core::session::SessionMetadata; pub use bitfun_services_integrations::remote_connect::bot::{ - parse_command, BotAction, BotActionStyle, BotChatState, BotCommand, BotDisplayMode, - BotInteractionHandler, BotInteractiveRequest, BotMessageSender, BotQuestion, BotQuestionOption, - BotWorkspaceChoice, BotWorkspaceRef, PendingAction, RemoteDeviceTarget, + is_acp_session_provider, parse_command, remote_rpc_error_message, remote_session_json_is_acp, + BotAction, BotActionStyle, BotChatState, BotCommand, BotDisplayMode, BotInteractionHandler, + BotInteractiveRequest, BotMessageSender, BotQuestion, BotQuestionOption, BotWorkspaceChoice, + BotWorkspaceRef, PendingAction, RemoteDeviceTarget, }; +#[derive(Debug, Clone, PartialEq, Eq)] +enum BotResumeWorkspaceError { + NeedWorkspace, + NeedAssistant, +} + +/// Workspace used for local bot resume / ACP guards. +/// +/// Must follow `display_mode`: Pro → `current_workspace`, Assistant → +/// `current_assistant`. Do not prefer a leftover Pro workspace while in +/// Assistant mode. +fn resolve_bot_resume_workspace( + state: &BotChatState, +) -> Result { + if state.display_mode == BotDisplayMode::Pro { + state + .current_workspace + .clone() + .ok_or(BotResumeWorkspaceError::NeedWorkspace) + } else { + state + .current_assistant + .as_ref() + .map(|path| BotWorkspaceRef::local(path.clone())) + .ok_or(BotResumeWorkspaceError::NeedAssistant) + } +} + +fn session_metadata_is_acp(metadata: &SessionMetadata) -> bool { + is_acp_session_provider( + metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get(SESSION_PROVIDER_METADATA_KEY)) + .and_then(Value::as_str), + ) +} + +/// Fail-closed ACP classification for a local metadata load. +/// +/// `Ok(Some(meta))` uses provider; anything else (missing workspace, IO error, +/// missing metadata) is treated as ACP so the session cannot enter the native +/// send path by accident. +fn classify_loaded_session_as_acp(load: Result, ()>) -> bool { + match load { + Ok(Some(metadata)) => session_metadata_is_acp(metadata), + _ => true, + } +} + +#[derive(Debug, Clone, PartialEq)] +enum FilteredRemoteResumePage { + Entries { + sessions: Vec, + has_more: bool, + }, + /// Server page had rows, but all were ACP and more pages remain. + SkipToNextPage, + NoSessions, +} + +fn filtered_remote_resume_page( + raw_sessions: Vec, + has_more: bool, +) -> FilteredRemoteResumePage { + let sessions: Vec = raw_sessions + .into_iter() + .filter(|sess| !remote_session_json_is_acp(sess)) + .collect(); + if sessions.is_empty() { + if has_more { + FilteredRemoteResumePage::SkipToNextPage + } else { + FilteredRemoteResumePage::NoSessions + } + } else { + FilteredRemoteResumePage::Entries { sessions, has_more } + } +} + +/// Remote chat send: treat `RemoteResponse::Error` as failure, never as success. +fn decide_remote_chat_send_result(resp_json: &str) -> Result<(), String> { + match remote_rpc_error_message(resp_json) { + Some(message) => Err(message), + None => Ok(()), + } +} + +fn acp_session_unsupported_view(s: &'static BotStrings) -> MenuView { + MenuView::plain(s.acp_session_unsupported) + .with_items(vec![MenuItem::default(s.item_back, "/menu")]) +} + +/// Max consecutive ACP-only remote pages that `/resume` may auto-skip in one +/// request. Each skip is a full relay round-trip; keep this small and hand +/// paging back to the user when the budget is exhausted. +const REMOTE_RESUME_ACP_SKIP_LIMIT: usize = 5; + +/// Whether another automatic ACP-only page skip is allowed. +/// +/// `consecutive_skips_so_far` counts ACP-only pages already fetched in this +/// `/resume` chain (0 on the first page). Returning false means hand paging +/// back to the user instead of issuing another relay RPC. +fn may_auto_skip_another_acp_page(consecutive_skips_so_far: usize) -> bool { + consecutive_skips_so_far + 1 < REMOTE_RESUME_ACP_SKIP_LIMIT +} + +#[cfg(test)] +thread_local! { + /// Test-only override for `local_session_is_acp` so UX regressions that need + /// a forward path can force a known native session without spinning up disk. + static LOCAL_ACP_GUARD_OVERRIDE: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +async fn local_session_is_acp(workspace: &BotWorkspaceRef, session_id: &str) -> bool { + #[cfg(test)] + if let Some(forced) = LOCAL_ACP_GUARD_OVERRIDE.with(|cell| cell.get()) { + let _ = (workspace, session_id); + return forced; + } + + let Some(storage_path) = resolve_bot_session_storage_path_for_ref(workspace).await else { + return classify_loaded_session_as_acp(Err(())); + }; + let Ok(pm) = crate::infrastructure::PathManager::new() else { + return classify_loaded_session_as_acp(Err(())); + }; + let Ok(store) = crate::agentic::persistence::PersistenceManager::new(std::sync::Arc::new(pm)) + else { + return classify_loaded_session_as_acp(Err(())); + }; + match store.load_session_metadata(&storage_path, session_id).await { + Ok(Some(metadata)) => classify_loaded_session_as_acp(Ok(Some(&metadata))), + Ok(None) => classify_loaded_session_as_acp(Ok(None)), + Err(_) => classify_loaded_session_as_acp(Err(())), + } +} + // ── Constants ────────────────────────────────────────────────────── /// How many invalid replies are tolerated before pending state is auto-cleared. @@ -1495,6 +1636,46 @@ async fn start_resume( state: &mut BotChatState, page: usize, s: &'static BotStrings, +) -> HandleResult { + Box::pin(start_resume_inner(state, page, 0, s)).await +} + +fn remote_resume_acp_skip_handoff( + state: &mut BotChatState, + page: usize, + s: &'static BotStrings, +) -> HandleResult { + state.set_pending(PendingAction::SelectSession { + options: Vec::new(), + page, + has_more: true, + }); + let items = vec![ + MenuItem::default(s.item_next_page, "0"), + MenuItem::default(s.item_back, "/menu"), + ]; + let title = if let Some(dev) = state.active_remote_device.as_ref() { + format!( + "{} · {} · #{}", + s.resume_page_label, + dev.device_name, + page + 1 + ) + } else { + format!("{} · #{}", s.resume_page_label, page + 1) + }; + let view = MenuView::plain(title) + .with_body(s.resume_acp_only_pages_hint.to_string()) + .with_items(items) + .with_footer(s.footer_reply_session_or_next); + result_from_menu(state, view) +} + +async fn start_resume_inner( + state: &mut BotChatState, + page: usize, + consecutive_acp_skips: usize, + s: &'static BotStrings, ) -> HandleResult { // ── Remote device branch ── if state.active_remote_device.is_some() { @@ -1539,7 +1720,7 @@ async fn start_resume( .with_items(vec![MenuItem::default(s.item_back, "/menu")]), ); } - let sessions = val + let raw_sessions = val .get("sessions") .and_then(|v| v.as_array()) .cloned() @@ -1548,9 +1729,27 @@ async fn start_resume( .get("has_more") .and_then(|v| v.as_bool()) .unwrap_or(false); - if sessions.is_empty() { - return result_from_menu(state, need_session_view(state, s)); - } + let (sessions, has_more) = match filtered_remote_resume_page(raw_sessions, has_more) + { + FilteredRemoteResumePage::SkipToNextPage => { + if !may_auto_skip_another_acp_page(consecutive_acp_skips) { + return remote_resume_acp_skip_handoff(state, page, s); + } + return Box::pin(start_resume_inner( + state, + page + 1, + consecutive_acp_skips + 1, + s, + )) + .await; + } + FilteredRemoteResumePage::NoSessions => { + return result_from_menu(state, need_session_view(state, s)); + } + FilteredRemoteResumePage::Entries { sessions, has_more } => { + (sessions, has_more) + } + }; let mut options: Vec<(String, String)> = Vec::new(); let mut body = String::new(); let mut items = Vec::new(); @@ -1629,34 +1828,29 @@ async fn start_resume( } // ── Local branch (original logic) ── + let _ = consecutive_acp_skips; use crate::agentic::persistence::PersistenceManager; use crate::infrastructure::PathManager; - let workspace_ref = if state.display_mode == BotDisplayMode::Pro { - match state.current_workspace.clone() { - Some(workspace) => workspace, - None => { - return result_from_menu( - state, - MenuView::plain(s.no_workspace).with_items(vec![ - MenuItem::primary(s.item_switch_workspace, "/switch"), - MenuItem::default(s.item_back, "/menu"), - ]), - ); - } + let workspace_ref = match resolve_bot_resume_workspace(state) { + Ok(workspace) => workspace, + Err(BotResumeWorkspaceError::NeedWorkspace) => { + return result_from_menu( + state, + MenuView::plain(s.no_workspace).with_items(vec![ + MenuItem::primary(s.item_switch_workspace, "/switch"), + MenuItem::default(s.item_back, "/menu"), + ]), + ); } - } else { - match &state.current_assistant { - Some(p) => BotWorkspaceRef::local(p.clone()), - None => { - return result_from_menu( - state, - MenuView::plain(s.no_assistant).with_items(vec![ - MenuItem::primary(s.item_switch_assistant, "/switch"), - MenuItem::default(s.item_back, "/menu"), - ]), - ); - } + Err(BotResumeWorkspaceError::NeedAssistant) => { + return result_from_menu( + state, + MenuView::plain(s.no_assistant).with_items(vec![ + MenuItem::primary(s.item_switch_assistant, "/switch"), + MenuItem::default(s.item_back, "/menu"), + ]), + ); } }; @@ -1693,7 +1887,10 @@ async fn start_resume( } }; let all_meta = match store.list_session_metadata(&storage_path).await { - Ok(m) => m, + Ok(m) => m + .into_iter() + .filter(|sess| !session_metadata_is_acp(sess)) + .collect::>(), Err(e) => { return result_from_menu( state, @@ -1768,11 +1965,41 @@ async fn select_session( session_name: &str, s: &'static BotStrings, ) -> HandleResult { + let resume_workspace = if state.active_remote_device.is_none() { + match resolve_bot_resume_workspace(state) { + Ok(workspace) => { + if local_session_is_acp(&workspace, session_id).await { + return result_from_menu(state, acp_session_unsupported_view(s)); + } + Some(workspace) + } + Err(BotResumeWorkspaceError::NeedWorkspace) => { + return result_from_menu( + state, + MenuView::plain(s.no_workspace).with_items(vec![ + MenuItem::primary(s.item_switch_workspace, "/switch"), + MenuItem::default(s.item_back, "/menu"), + ]), + ); + } + Err(BotResumeWorkspaceError::NeedAssistant) => { + return result_from_menu( + state, + MenuView::plain(s.no_assistant).with_items(vec![ + MenuItem::primary(s.item_switch_assistant, "/switch"), + MenuItem::default(s.item_back, "/menu"), + ]), + ); + } + } + } else { + None + }; + state.current_session_id = Some(session_id.to_string()); info!("Bot resumed session: {session_id}"); - let last_pair = - load_last_dialog_pair_from_turns(state.current_workspace.as_ref(), session_id).await; + let last_pair = load_last_dialog_pair_from_turns(resume_workspace.as_ref(), session_id).await; let mut body = format!("{}{}\n", s.resume_resumed_prefix, session_name); if let Some((user_text, ai_text)) = last_pair { body.push('\n'); @@ -2687,7 +2914,14 @@ async fn handle_chat( }); let cmd_json = serde_json::to_string(&cmd).unwrap_or_default(); match exec_remote_rpc(state, &cmd_json).await { - Ok(_resp) => { + Ok(resp) => { + if let Err(message) = decide_remote_chat_send_result(&resp) { + return result_from_menu( + state, + MenuView::plain(format!("{}{message}", s.devices_send_failed_prefix)) + .with_items(vec![MenuItem::default(s.item_back, "/menu")]), + ); + } // The response contains {resp: "message_sent", session_id, turn_id}. // For now, show a brief confirmation. A future improvement could // poll for the agent's reply and stream it back. @@ -2708,20 +2942,35 @@ async fn handle_chat( } else { // ── Local branch (original logic) ── - if state.display_mode == BotDisplayMode::Pro && state.current_workspace.is_none() { - return result_from_menu( - state, - MenuView::plain(s.no_workspace).with_items(vec![ - MenuItem::primary(s.item_switch_workspace, "/switch"), - MenuItem::default(s.item_back, "/menu"), - ]), - ); - } if state.current_session_id.is_none() { return result_from_menu(state, need_session_view(state, s)); } let session_id = state.current_session_id.clone().unwrap(); + let workspace = match resolve_bot_resume_workspace(state) { + Ok(workspace) => workspace, + Err(BotResumeWorkspaceError::NeedWorkspace) => { + return result_from_menu( + state, + MenuView::plain(s.no_workspace).with_items(vec![ + MenuItem::primary(s.item_switch_workspace, "/switch"), + MenuItem::default(s.item_back, "/menu"), + ]), + ); + } + Err(BotResumeWorkspaceError::NeedAssistant) => { + return result_from_menu( + state, + MenuView::plain(s.no_assistant).with_items(vec![ + MenuItem::primary(s.item_switch_assistant, "/switch"), + MenuItem::default(s.item_back, "/menu"), + ]), + ); + } + }; + if local_session_is_acp(&workspace, &session_id).await { + return result_from_menu(state, acp_session_unsupported_view(s)); + } let turn_id = format!("turn_{}", uuid::Uuid::new_v4()); // Pick the agent type from the actual session — NOT a hardcoded @@ -3365,7 +3614,9 @@ mod handle_chat_tests { state.current_assistant = Some("/tmp/a".into()); state.current_session_id = Some("s1".into()); let s = strings_for(BotLanguage::ZhCN); + LOCAL_ACP_GUARD_OVERRIDE.with(|cell| cell.set(Some(false))); let result = handle_chat(&mut state, "hello bitfun", vec![], s).await; + LOCAL_ACP_GUARD_OVERRIDE.with(|cell| cell.set(None)); assert!( result.forward_to_session.is_some(), @@ -3391,3 +3642,175 @@ mod handle_chat_tests { ); } } + +#[cfg(test)] +mod acp_wiring_tests { + use super::*; + use bitfun_core_types::{SESSION_PROVIDER_ACP, SESSION_PROVIDER_METADATA_KEY}; + use serde_json::json; + + fn meta_with_provider(provider: Option<&str>) -> SessionMetadata { + let mut metadata = SessionMetadata::new( + "s1".to_string(), + "n".to_string(), + "agentic".to_string(), + "model".to_string(), + ); + if let Some(provider) = provider { + metadata.custom_metadata = Some(json!({ SESSION_PROVIDER_METADATA_KEY: provider })); + } + metadata + } + + #[test] + fn resume_workspace_follows_display_mode_not_leftover_pro_workspace() { + let mut state = BotChatState::new("c".into()); + state.display_mode = BotDisplayMode::Assistant; + state.current_workspace = Some(BotWorkspaceRef::local("/tmp/stale-pro")); + state.current_assistant = Some("/tmp/assistant".into()); + + let resolved = resolve_bot_resume_workspace(&state).expect("assistant workspace"); + assert_eq!(resolved.path, "/tmp/assistant"); + + state.display_mode = BotDisplayMode::Pro; + let resolved = resolve_bot_resume_workspace(&state).expect("pro workspace"); + assert_eq!(resolved.path, "/tmp/stale-pro"); + } + + #[test] + fn resume_workspace_errors_match_mode_requirements() { + let mut state = BotChatState::new("c".into()); + state.display_mode = BotDisplayMode::Pro; + assert_eq!( + resolve_bot_resume_workspace(&state), + Err(BotResumeWorkspaceError::NeedWorkspace) + ); + + state.display_mode = BotDisplayMode::Assistant; + assert_eq!( + resolve_bot_resume_workspace(&state), + Err(BotResumeWorkspaceError::NeedAssistant) + ); + } + + #[test] + fn local_acp_classification_is_fail_closed() { + let acp = meta_with_provider(Some(SESSION_PROVIDER_ACP)); + let native = meta_with_provider(None); + assert!(classify_loaded_session_as_acp(Ok(Some(&acp)))); + assert!(!classify_loaded_session_as_acp(Ok(Some(&native)))); + assert!( + classify_loaded_session_as_acp(Ok(None)), + "missing metadata must not open the native send path" + ); + assert!( + classify_loaded_session_as_acp(Err(())), + "load errors must not open the native send path" + ); + } + + #[test] + fn remote_acp_auto_skip_budget_is_consecutive_not_absolute_page() { + assert!(may_auto_skip_another_acp_page(0)); + assert!(may_auto_skip_another_acp_page( + REMOTE_RESUME_ACP_SKIP_LIMIT - 2 + )); + assert!( + !may_auto_skip_another_acp_page(REMOTE_RESUME_ACP_SKIP_LIMIT - 1), + "the Nth consecutive ACP-only page must hand paging back to the user" + ); + // Absolute high page numbers are irrelevant — only consecutive skips count. + assert!( + may_auto_skip_another_acp_page(0), + "a fresh /resume chain (including after user replies 0) resets the budget" + ); + } + + #[tokio::test] + async fn select_session_requires_display_mode_workspace_before_setting_current() { + let s = strings_for(BotLanguage::ZhCN); + let mut state = BotChatState::new("c".into()); + state.display_mode = BotDisplayMode::Assistant; + state.current_workspace = Some(BotWorkspaceRef::local("/tmp/stale-pro")); + // Assistant mode without current_assistant must not select. + let result = select_session(&mut state, "s1", "name", s).await; + assert!(state.current_session_id.is_none()); + assert!(result.reply.contains(s.no_assistant)); + + state.current_assistant = Some("/tmp/assistant".into()); + LOCAL_ACP_GUARD_OVERRIDE.with(|cell| cell.set(Some(false))); + let result = select_session(&mut state, "s1", "name", s).await; + LOCAL_ACP_GUARD_OVERRIDE.with(|cell| cell.set(None)); + assert_eq!(state.current_session_id.as_deref(), Some("s1")); + assert!(result.reply.contains(s.resume_resumed_prefix)); + } + + #[test] + fn remote_resume_page_all_acp_with_more_skips_instead_of_dead_end() { + let page = filtered_remote_resume_page( + vec![ + json!({"session_id": "a", "session_kind": "acp"}), + json!({"session_id": "b", "session_kind": "acp"}), + ], + true, + ); + assert_eq!(page, FilteredRemoteResumePage::SkipToNextPage); + } + + #[test] + fn remote_resume_page_all_acp_without_more_is_empty() { + let page = filtered_remote_resume_page( + vec![json!({"session_id": "a", "session_kind": "acp"})], + false, + ); + assert_eq!(page, FilteredRemoteResumePage::NoSessions); + } + + #[test] + fn remote_resume_page_keeps_native_and_has_more() { + let page = filtered_remote_resume_page( + vec![ + json!({"session_id": "a", "session_kind": "acp"}), + json!({"session_id": "n", "session_kind": "native", "name": "ok"}), + ], + true, + ); + match page { + FilteredRemoteResumePage::Entries { sessions, has_more } => { + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0]["session_id"], "n"); + assert!(has_more); + } + other => panic!("expected entries, got {other:?}"), + } + } + + #[test] + fn remote_chat_send_treats_error_resp_as_failure() { + assert_eq!( + decide_remote_chat_send_result( + r#"{"resp":"error","message":"ACP session requires ACP control capability"}"# + ), + Err("ACP session requires ACP control capability".to_string()) + ); + assert_eq!( + decide_remote_chat_send_result(r#"{"resp":"message_sent","turn_id":"t1"}"#), + Ok(()) + ); + } + + #[tokio::test] + async fn handle_chat_blocks_when_local_acp_guard_says_acp() { + let mut state = BotChatState::new("peer".into()); + state.paired = true; + state.current_assistant = Some("/tmp/a".into()); + state.current_session_id = Some("acp-s1".into()); + let s = strings_for(BotLanguage::ZhCN); + LOCAL_ACP_GUARD_OVERRIDE.with(|cell| cell.set(Some(true))); + let result = handle_chat(&mut state, "hello", vec![], s).await; + LOCAL_ACP_GUARD_OVERRIDE.with(|cell| cell.set(None)); + + assert!(result.forward_to_session.is_none()); + assert!(result.reply.contains(s.acp_session_unsupported)); + } +} diff --git a/src/crates/assembly/core/src/service/remote_connect/mod.rs b/src/crates/assembly/core/src/service/remote_connect/mod.rs index fddca3c77e..764535a6a8 100644 --- a/src/crates/assembly/core/src/service/remote_connect/mod.rs +++ b/src/crates/assembly/core/src/service/remote_connect/mod.rs @@ -58,7 +58,37 @@ pub use pairing::{PairingProtocol, PairingState}; pub use qr_generator::QrGenerator; pub use relay_client::ensure_rustls_crypto_provider; pub use relay_client::RelayClient; -pub use remote_server::RemoteServer; +pub use remote_server::{ + clear_remote_acp_control_session, remote_acp_control_host_installed, + set_remote_acp_control_host, RemoteServer, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedRemoteSessionWorkspaceScope { + pub workspace_path: String, + pub session_storage_path: std::path::PathBuf, + pub remote_connection_id: Option, + pub remote_ssh_host: Option, +} + +/// Resolve the complete workspace scope for a remote-controlled session. +#[cfg(feature = "remote-connect")] +pub async fn resolve_remote_session_workspace_scope( + session_id: &str, +) -> Option { + crate::service_agent_runtime::CoreServiceAgentRuntime::resolve_session_workspace_scope( + session_id, + ) + .await +} + +/// Resolve the on-disk session storage directory for a remote-controlled session. +#[cfg(feature = "remote-connect")] +pub async fn resolve_remote_session_storage_dir(session_id: &str) -> Option { + resolve_remote_session_workspace_scope(session_id) + .await + .map(|scope| scope.session_storage_path) +} use anyhow::Result; use bitfun_services_integrations::remote_connect::upload_mobile_web_to_relay; @@ -345,6 +375,7 @@ impl AuthorizedCredentialResolution { Self { response: remote_server::RemoteResponse::Error { message: message.into(), + code: None, }, _host_lease: None, } @@ -796,9 +827,13 @@ impl RemoteConnectService { message: String, ) { let server = RemoteServer::new(*shared_secret); - if let Ok((enc, nonce)) = - server.encrypt_response(&remote_server::RemoteResponse::Error { message }, None) - { + if let Ok((enc, nonce)) = server.encrypt_response( + &remote_server::RemoteResponse::Error { + message, + code: None, + }, + None, + ) { if let Some(ref client) = *relay_arc.read().await { let _ = client .send_relay_response(correlation_id, &enc, &nonce) @@ -1100,7 +1135,33 @@ impl RemoteConnectService { let server_guard = server_arc.read().await; if let Some(ref server) = *server_guard { match server.decrypt_command(&encrypted_data, &nonce) { - Ok((cmd, request_id)) => { + Ok(remote_server::DecryptedRemoteEnvelope::Rejected { + response, + request_id, + }) => { + handled_as_active_command = true; + match server.encrypt_response(&response, request_id.as_deref()) + { + Ok((enc, resp_nonce)) => { + if let Some(ref client) = *relay_arc.read().await { + let _ = client + .send_relay_response( + &correlation_id, + &enc, + &resp_nonce, + ) + .await; + } + } + Err(e) => { + error!("Failed to encrypt unsupported response: {e}"); + } + } + } + Ok(remote_server::DecryptedRemoteEnvelope::Command { + command: cmd, + request_id, + }) => { handled_as_active_command = true; debug!("Remote command decrypted"); // Account-credential commands are answered @@ -2435,7 +2496,7 @@ mod tests { .await; assert!(matches!( response.response, - remote_server::RemoteResponse::Error { message } + remote_server::RemoteResponse::Error { message, code: None } if message.contains("no longer matches") )); } @@ -2696,7 +2757,10 @@ mod tests { match response.response { // The person is standing there watching a watch spin; a generic // failure would send them to the wrong fix. - remote_server::RemoteResponse::Error { message } => { + remote_server::RemoteResponse::Error { + message, + code: None, + } => { assert!(message.contains("relay rejected the request"), "{message}"); } other => panic!("expected the relay reason to survive, got {other:?}"), diff --git a/src/crates/assembly/core/src/service/remote_connect/remote_server.rs b/src/crates/assembly/core/src/service/remote_connect/remote_server.rs index 71cbd2b2bd..196a59bc54 100644 --- a/src/crates/assembly/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/assembly/core/src/service/remote_connect/remote_server.rs @@ -15,13 +15,20 @@ use std::sync::{Arc, OnceLock}; use super::encryption; use bitfun_services_integrations::remote_connect::{ + acp_cancel_response, acp_commands_response, acp_native_session_control_unsupported, + acp_native_tool_interaction_unsupported, acp_options_response, acp_permission_respond_response, + acp_permission_respond_unsupported, acp_plan_response, acp_send_response, build_remote_image_contexts, cancel_remote_task, generate_remote_initial_sync, handle_remote_command, handle_remote_interaction_command, handle_remote_poll_command, handle_remote_session_command, handle_remote_workspace_command, - handle_remote_workspace_file_command, submit_remote_dialog, RemoteCancelTaskRequest, - RemoteCommandRuntimeHost, RemoteConnectSubmissionSource, RemoteDialogSubmissionPolicy, - RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteImageContext, - RemoteSessionTrackerRegistry, + handle_remote_workspace_file_command, parse_remote_command, submit_remote_dialog, + RemoteAcpCancelRequest, RemoteAcpControlError, RemoteAcpControlRuntimeHost, + RemoteAcpGetCommandsRequest, RemoteAcpGetOptionsRequest, RemoteAcpGetPlanRequest, + RemoteAcpPermissionRespondRequest, RemoteAcpSendRequest, RemoteAcpSetOptionRequest, + RemoteCancelTaskRequest, RemoteCommandParseError, RemoteCommandRuntimeHost, + RemoteConnectSubmissionSource, RemoteDialogSubmissionPolicy, RemoteDialogSubmissionRequest, + RemoteDialogSubmitOutcome, RemoteImageContext, RemoteSessionTrackerRegistry, + ACP_SESSION_REQUIRES_ACP_CONTROL_MESSAGE, UNSUPPORTED_REMOTE_CAPABILITY, }; pub use bitfun_services_integrations::remote_connect::{ ActiveTurnSnapshot, AssistantEntry, ChatImageAttachment, ChatMessage, ChatMessageItem, @@ -32,6 +39,40 @@ pub use bitfun_services_integrations::remote_connect::{ pub type EncryptedPayload = (String, String); +static ACP_CONTROL_HOST: OnceLock> = OnceLock::new(); + +/// Inject the Desktop-owned ACP remote-control adapter. Safe to call once at +/// startup; later calls are ignored so tests/restarts do not panic. +pub fn set_remote_acp_control_host(host: Arc) { + let _ = ACP_CONTROL_HOST.set(host); +} + +pub fn remote_acp_control_host_installed() -> bool { + ACP_CONTROL_HOST.get().is_some() +} + +pub fn clear_remote_acp_control_session(session_id: &str) { + if let Some(host) = ACP_CONTROL_HOST.get() { + host.clear_session_idempotency(session_id); + } +} + +fn remote_acp_control_host() -> Option> { + ACP_CONTROL_HOST.get().cloned() +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DecryptedRemoteEnvelope { + Command { + command: RemoteCommand, + request_id: Option, + }, + Rejected { + response: RemoteResponse, + request_id: Option, + }, +} + /// Convert legacy `ImageAttachment` to unified `ImageContextData`. pub fn images_to_contexts( images: Option<&Vec>, @@ -178,7 +219,12 @@ impl RemoteCommandRuntimeHost for CoreRemoteCommandRuntimeHost<'_> { async fn handle_session_command(&self, command: &RemoteCommand) -> RemoteResponse { let host = match CoreServiceAgentRuntime::remote_session_host() { Ok(host) => host, - Err(message) => return RemoteResponse::Error { message }, + Err(message) => { + return RemoteResponse::Error { + message, + code: None, + } + } }; handle_remote_session_command(&host, command).await } @@ -248,6 +294,7 @@ impl RemoteCommandRuntimeHost for CoreRemoteCommandRuntimeHost<'_> { if let Err(e) = std::fs::create_dir_all(&path) { return RemoteResponse::Error { message: format!("Failed to create workspace directory: {e}"), + code: None, }; } // Now delegate to the workspace host to actually open/track it. @@ -264,6 +311,7 @@ impl RemoteCommandRuntimeHost for CoreRemoteCommandRuntimeHost<'_> { } _ => RemoteResponse::Error { message: "Unsupported device command".to_string(), + code: None, }, } } @@ -284,6 +332,197 @@ impl RemoteCommandRuntimeHost for CoreRemoteCommandRuntimeHost<'_> { cancel_remote_task(&host, request).await } + async fn handle_acp_control_command(&self, command: &RemoteCommand) -> RemoteResponse { + match command { + RemoteCommand::AcpPermissionRespond { + session_id, + permission_id, + option_id, + request_id, + } => { + let Some(host) = remote_acp_control_host() else { + return acp_permission_respond_unsupported(session_id, request_id.clone()); + }; + acp_permission_respond_response( + host.permission_respond(RemoteAcpPermissionRespondRequest { + session_id: session_id.clone(), + permission_id: permission_id.clone(), + option_id: option_id.clone(), + request_id: request_id.clone(), + }) + .await, + ) + } + RemoteCommand::AcpSendMessage { + session_id, + content, + images, + image_contexts, + request_id, + } => { + let Some(host) = remote_acp_control_host() else { + return RemoteAcpControlError::unsupported( + session_id.clone(), + request_id.clone(), + ) + .into_response(); + }; + acp_send_response( + host.send_message(RemoteAcpSendRequest { + session_id: session_id.clone(), + content: content.clone(), + images: images.clone(), + image_contexts: image_contexts.clone(), + request_id: request_id.clone(), + }) + .await, + ) + } + RemoteCommand::AcpCancelTurn { + session_id, + turn_id, + request_id, + } => { + let Some(host) = remote_acp_control_host() else { + return RemoteAcpControlError::unsupported( + session_id.clone(), + request_id.clone(), + ) + .into_response(); + }; + acp_cancel_response( + host.cancel_turn(RemoteAcpCancelRequest { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + request_id: request_id.clone(), + }) + .await, + ) + } + RemoteCommand::AcpGetOptions { + session_id, + request_id, + } => { + let Some(host) = remote_acp_control_host() else { + return RemoteAcpControlError::unsupported( + session_id.clone(), + request_id.clone(), + ) + .into_response(); + }; + acp_options_response( + host.get_options(RemoteAcpGetOptionsRequest { + session_id: session_id.clone(), + request_id: request_id.clone(), + }) + .await, + ) + } + RemoteCommand::AcpSetOption { + session_id, + config_id, + value, + request_id, + } => { + let Some(host) = remote_acp_control_host() else { + return RemoteAcpControlError::unsupported( + session_id.clone(), + request_id.clone(), + ) + .into_response(); + }; + acp_options_response( + host.set_option(RemoteAcpSetOptionRequest { + session_id: session_id.clone(), + config_id: config_id.clone(), + value: value.clone(), + request_id: request_id.clone(), + }) + .await, + ) + } + RemoteCommand::AcpGetCommands { + session_id, + request_id, + } => { + let Some(host) = remote_acp_control_host() else { + return RemoteAcpControlError::unsupported( + session_id.clone(), + request_id.clone(), + ) + .into_response(); + }; + acp_commands_response( + host.get_commands(RemoteAcpGetCommandsRequest { + session_id: session_id.clone(), + request_id: request_id.clone(), + }) + .await, + ) + } + RemoteCommand::AcpGetPlan { + session_id, + request_id, + } => { + let Some(host) = remote_acp_control_host() else { + return RemoteAcpControlError::unsupported( + session_id.clone(), + request_id.clone(), + ) + .into_response(); + }; + acp_plan_response( + host.get_plan(RemoteAcpGetPlanRequest { + session_id: session_id.clone(), + request_id: request_id.clone(), + }) + .await, + ) + } + _ => RemoteResponse::Error { + message: format!( + "{UNSUPPORTED_REMOTE_CAPABILITY}: {ACP_SESSION_REQUIRES_ACP_CONTROL_MESSAGE}" + ), + code: Some(UNSUPPORTED_REMOTE_CAPABILITY.to_string()), + }, + } + } + + async fn reject_native_session_control_for_acp( + &self, + session_id: &str, + command_name: &str, + ) -> Option { + let host = remote_acp_control_host()?; + if !host.is_acp_session(session_id).await { + return None; + } + Some(acp_native_session_control_unsupported( + session_id, + command_name, + )) + } + + async fn reject_native_tool_interaction_for_acp( + &self, + session_id: Option<&str>, + tool_id: &str, + ) -> Option { + let host = remote_acp_control_host()?; + if let Some(session_id) = session_id { + if host.is_acp_session(session_id).await { + return Some(acp_native_tool_interaction_unsupported( + Some(session_id), + tool_id, + )); + } + } + if host.is_acp_permission_id(tool_id).await { + return Some(acp_native_tool_interaction_unsupported(session_id, tool_id)); + } + None + } + fn legacy_image_contexts(&self, images: Option<&[ImageAttachment]>) -> Vec { build_core_image_contexts(images) } @@ -320,16 +559,33 @@ impl RemoteServer { &self, encrypted_data: &str, nonce: &str, - ) -> Result<(RemoteCommand, Option)> { + ) -> Result { let json = encryption::decrypt_from_base64(&self.shared_secret, encrypted_data, nonce)?; let value: Value = serde_json::from_str(&json).map_err(|e| anyhow!("parse json: {e}"))?; let request_id = value .get("_request_id") .and_then(|v| v.as_str()) .map(String::from); - let cmd: RemoteCommand = - serde_json::from_value(value).map_err(|e| anyhow!("parse command: {e}"))?; - Ok((cmd, request_id)) + match parse_remote_command(value) { + Ok(command) => Ok(DecryptedRemoteEnvelope::Command { + command, + request_id, + }), + // Both structured rejections must reach the client as a *response*. + // Dropping `InvalidAcpParams` into the `Err` branch below would make + // the host answer nothing at all, and the phone's silence probe + // would then misreport a bad payload as "host too old". + Err( + error @ (RemoteCommandParseError::Unsupported { .. } + | RemoteCommandParseError::InvalidAcpParams { .. }), + ) => Ok(DecryptedRemoteEnvelope::Rejected { + response: error.into_remote_response(), + request_id, + }), + Err(RemoteCommandParseError::Invalid { message }) => { + Err(anyhow!("parse command: {message}")) + } + } } pub fn encrypt_response( @@ -387,7 +643,13 @@ mod tests { }); let json = cmd_json.to_string(); let (enc, nonce) = encryption::encrypt_to_base64(&shared, &json).unwrap(); - let (decoded, req_id) = bridge.decrypt_command(&enc, &nonce).unwrap(); + let DecryptedRemoteEnvelope::Command { + command: decoded, + request_id: req_id, + } = bridge.decrypt_command(&enc, &nonce).unwrap() + else { + panic!("send_message should decrypt as a command"); + }; assert_eq!(req_id.as_deref(), Some("req_abc")); if let RemoteCommand::SendMessage { @@ -403,6 +665,57 @@ mod tests { } } + /// Every structured rejection must come back as an answerable *response* + /// carrying the original `_request_id`. If a malformed ACP payload were + /// dropped into the transport-error branch instead, the host would go + /// silent, and the client's silence probe would then misdiagnose a bad + /// payload as "host predates the ACP command family". + #[test] + fn structured_acp_rejections_answer_instead_of_going_silent() { + let alice = KeyPair::generate(); + let shared = alice.derive_shared_secret(&alice.public_key_bytes()); + let bridge = RemoteServer::new(shared); + + for (payload, expected_code) in [ + ( + // Known ACP command, `session_id` missing. + serde_json::json!({ + "cmd": "acp_get_plan", + "_request_id": "req_bad_params" + }), + bitfun_services_integrations::remote_connect::INVALID_ACP_COMMAND_PARAMS, + ), + ( + // Unknown future ACP command name. + serde_json::json!({ + "cmd": "acp_not_a_real_command", + "_request_id": "req_bad_params" + }), + bitfun_services_integrations::remote_connect::UNSUPPORTED_REMOTE_CAPABILITY, + ), + ] { + let (enc, nonce) = + encryption::encrypt_to_base64(&shared, &payload.to_string()).unwrap(); + let envelope = bridge + .decrypt_command(&enc, &nonce) + .expect("a structured rejection is not a transport failure"); + let DecryptedRemoteEnvelope::Rejected { + response, + request_id, + } = envelope + else { + panic!("{payload} must be rejected, not accepted as a command"); + }; + assert_eq!(request_id.as_deref(), Some("req_bad_params")); + match response { + RemoteResponse::Error { code, .. } => { + assert_eq!(code.as_deref(), Some(expected_code), "for {payload}") + } + other => panic!("expected a coded error for {payload}, got {other:?}"), + } + } + } + #[test] fn test_response_with_request_id() { let alice = KeyPair::generate(); @@ -640,6 +953,7 @@ mod tests { total_msg_count: None, message_snapshot: None, active_turn: Some(active_turn), + acp_projection: None, model_catalog: Box::new(None), }) .expect("serialize poll response"); diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index d6e1bbf3b6..4b7cd38c42 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -48,11 +48,12 @@ use bitfun_services_integrations::remote_connect::{ RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, RemoteImageContext, RemoteInitialSyncRuntimeHost, RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelFacts, RemotePermissionMode, - RemotePollRuntimeHost, RemoteRecentWorkspaceFacts, RemoteSessionMetadata, - RemoteSessionModelSelection, RemoteSessionRuntimeHost, RemoteSessionStateTracker, - RemoteSessionTrackerHost, RemoteTerminalPrewarmRequest, RemoteWorkspaceFacts, - RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind as RemoteConnectWorkspaceKind, - RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, + RemotePollRuntimeHost, RemoteRecentWorkspaceFacts, RemoteSessionControlFacts, + RemoteSessionMetadata, RemoteSessionModelSelection, RemoteSessionRuntimeHost, + RemoteSessionStateTracker, RemoteSessionTrackerHost, RemoteTerminalPrewarmRequest, + RemoteWorkspaceFacts, RemoteWorkspaceFileRuntimeHost, + RemoteWorkspaceKind as RemoteConnectWorkspaceKind, RemoteWorkspaceRuntimeHost, + RemoteWorkspaceUpdate, }; #[cfg(feature = "remote-connect")] use log::{debug, info}; @@ -241,13 +242,31 @@ async fn load_remote_session_metadata_for_workspace( Ok(metadata .into_iter() - .map(|session| RemoteSessionMetadata { - session_id: session.session_id, - name: session.session_name, - agent_type: session.agent_type, - created_at_ms: session.created_at, - last_active_at_ms: session.last_active_at, - turn_count: session.turn_count, + .map(|session| { + let provider = session + .custom_metadata + .as_ref() + .and_then(|custom| custom.get(bitfun_core_types::SESSION_PROVIDER_METADATA_KEY)) + .and_then(serde_json::Value::as_str); + let session_kind = + bitfun_runtime_ports::RemoteSessionKind::from_persisted_provider(provider); + let capabilities = if session_kind == bitfun_runtime_ports::RemoteSessionKind::Acp + && crate::service::remote_connect::remote_server::remote_acp_control_host_installed( + ) { + vec![bitfun_runtime_ports::REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string()] + } else { + Vec::new() + }; + RemoteSessionMetadata { + session_id: session.session_id, + name: session.session_name, + agent_type: session.agent_type, + created_at_ms: session.created_at, + last_active_at_ms: session.last_active_at, + turn_count: session.turn_count, + session_kind, + capabilities, + } }) .collect()) } @@ -1065,6 +1084,20 @@ fn scheduled_session_revert_port( Arc::new(ScheduledSessionManagementPort::new(coordinator, scheduler)) } +#[cfg(feature = "remote-connect")] +fn resolved_remote_session_workspace_scope( + binding: WorkspaceBinding, + session_storage_path: std::path::PathBuf, +) -> crate::service::remote_connect::ResolvedRemoteSessionWorkspaceScope { + let is_remote = binding.is_remote(); + crate::service::remote_connect::ResolvedRemoteSessionWorkspaceScope { + workspace_path: binding.logical_workspace_path_string(), + session_storage_path, + remote_connection_id: binding.connection_id().map(ToOwned::to_owned), + remote_ssh_host: is_remote.then(|| binding.session_identity.hostname.clone()), + } +} + pub(crate) struct CoreServiceAgentRuntime; impl CoreServiceAgentRuntime { @@ -1089,13 +1122,25 @@ impl CoreServiceAgentRuntime { }) } + #[cfg(feature = "remote-connect")] + pub(crate) async fn resolve_session_workspace_scope( + session_id: &str, + ) -> Option { + Self::resolve_session_workspace_binding(session_id) + .await + .map(|binding| { + let session_storage_path = binding.session_storage_dir(); + resolved_remote_session_workspace_scope(binding, session_storage_path) + }) + } + #[cfg(feature = "remote-connect")] pub(crate) async fn resolve_session_storage_dir( session_id: &str, ) -> Option { - Self::resolve_session_workspace_paths(session_id) + Self::resolve_session_workspace_scope(session_id) .await - .map(|(_, storage_dir)| storage_dir) + .map(|scope| scope.session_storage_path) } #[cfg(feature = "remote-connect")] @@ -2095,6 +2140,41 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { generate_remote_turn_id() } + async fn remote_session_control(&self, session_id: &str) -> RemoteSessionControlFacts { + let Some(session_storage_dir) = + CoreServiceAgentRuntime::resolve_session_storage_dir(session_id).await + else { + return RemoteSessionControlFacts::unknown(); + }; + match self + .coordinator + .get_session_manager() + .load_session_metadata(&session_storage_dir, session_id) + .await + { + Ok(Some(metadata)) => { + let provider = metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get(bitfun_core_types::SESSION_PROVIDER_METADATA_KEY)) + .and_then(serde_json::Value::as_str); + match bitfun_runtime_ports::RemoteSessionKind::from_persisted_provider(provider) { + bitfun_runtime_ports::RemoteSessionKind::Acp => { + let capabilities = if crate::service::remote_connect::remote_server::remote_acp_control_host_installed() + { + vec![bitfun_runtime_ports::REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string()] + } else { + Vec::new() + }; + RemoteSessionControlFacts::acp(capabilities) + } + _ => RemoteSessionControlFacts::native(), + } + } + _ => RemoteSessionControlFacts::unknown(), + } + } + async fn submit_dialog( &self, submission: RemoteDialogResolvedSubmission, @@ -2380,6 +2460,9 @@ impl RemotePollRuntimeHost for CoreRemotePollRuntimeHost<'_> { } fn sync_pending_permissions(&self, session_id: &str, tracker: &RemoteSessionStateTracker) { + bitfun_services_integrations::remote_connect::sync_acp_permission_mailbox_into_tracker( + session_id, tracker, + ); let Ok(manager) = crate::product_runtime::core_permission_request_manager() else { return; }; @@ -2574,6 +2657,7 @@ mod tests { use std::collections::HashSet; use bitfun_runtime_ports::SessionTranscriptReader; + use bitfun_services_core::workspace_identity::workspace_session_identity; use super::*; use crate::service::session::{ @@ -2594,6 +2678,46 @@ mod tests { ); } + #[test] + fn remote_session_workspace_scope_preserves_logical_and_storage_locations() { + let identity = workspace_session_identity( + "/srv/remote/project", + Some("connection-1"), + Some("remote.example"), + ) + .expect("remote workspace identity"); + let storage_path = std::path::PathBuf::from("/local/mirror/sessions"); + let scope = resolved_remote_session_workspace_scope( + WorkspaceBinding::new_remote( + None, + std::path::PathBuf::from("/srv/remote/project"), + "connection-1".to_string(), + "Remote host".to_string(), + identity, + ), + storage_path.clone(), + ); + + assert_eq!(scope.workspace_path, "/srv/remote/project"); + assert_eq!(scope.session_storage_path, storage_path); + assert_eq!(scope.remote_connection_id.as_deref(), Some("connection-1")); + assert_eq!(scope.remote_ssh_host.as_deref(), Some("remote.example")); + } + + #[test] + fn local_session_workspace_scope_omits_remote_facts() { + let storage_path = std::path::PathBuf::from("/local/project/.bitfun/sessions"); + let scope = resolved_remote_session_workspace_scope( + WorkspaceBinding::new(None, std::path::PathBuf::from("/local/project")), + storage_path.clone(), + ); + + assert_eq!(scope.workspace_path, "/local/project"); + assert_eq!(scope.session_storage_path, storage_path); + assert_eq!(scope.remote_connection_id, None); + assert_eq!(scope.remote_ssh_host, None); + } + #[test] fn targeted_rollback_restores_before_requiring_an_in_memory_session() { let source = include_str!("service_agent_runtime.rs"); diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index 7cb025a7f2..55ac0d4fe6 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -16,6 +16,70 @@ pub enum AgenticEventPriority { Low = 3, } +/// Who produced this envelope. Lives on the envelope, not on each event +/// variant, so subscribers can isolate without forking event types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgenticEventOrigin { + /// BitFun Agent Runtime owns the turn. + #[default] + NativeRuntime, + /// An externally projected ACP client session. + ExternalAcp, +} + +/// Which model drove a round. Native and external cases are variants so a +/// round cannot be both or neither. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum ModelRoundIdentity { + Native { + /// Resolved `AIModelConfig.id` used for this round. + model_config_id: String, + /// Provider model name sent on the request. + effective_model_name: String, + }, + External { + /// Owning provider, currently only `"acp"`. + provider: String, + /// Adapter identity within the provider, e.g. `"gemini"`. + client_id: String, + /// Model id reported by the external agent, when it reports one. + #[serde(default, skip_serializing_if = "Option::is_none")] + model_id: Option, + /// Display name reported by the external agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, + }, +} + +/// ACP-only presentation facts for a model round. Native rounds omit this. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct ModelRoundRenderHints { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_explore_grouping: bool, +} + +/// Slash command advertised by an ACP agent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpAvailableCommandFact { + pub name: String, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_hint: Option, +} + +/// One entry of an ACP agent execution plan. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpPlanEntryFact { + pub content: String, + pub priority: String, + pub status: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SubagentParentInfo { #[serde(rename = "toolCallId")] @@ -282,10 +346,9 @@ pub enum AgenticEvent { #[serde(default, skip_serializing_if = "Option::is_none")] round_group_id: Option, round_index: usize, - /// Resolved `AIModelConfig.id` used for this round. - model_config_id: String, - /// Provider model name sent on the request. - effective_model_name: String, + identity: ModelRoundIdentity, + #[serde(default, skip_serializing_if = "Option::is_none")] + render_hints: Option, }, /// Emitted as soon as an automatic retry supersedes one model attempt. @@ -366,6 +429,34 @@ pub enum AgenticEvent { queue_state: DeepReviewQueueState, }, + AcpContextUsageUpdated { + session_id: String, + turn_id: String, + client_id: String, + used: u64, + size: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + cost: Option, + }, + + AcpAvailableCommandsUpdated { + session_id: String, + client_id: String, + commands: Vec, + }, + + AcpPlanUpdated { + session_id: String, + turn_id: String, + client_id: String, + entries: Vec, + }, + + AcpSessionOptionsChanged { + session_id: String, + client_id: String, + }, + SystemError { session_id: Option, error: String, @@ -596,6 +687,9 @@ pub struct AgenticEventEnvelope { pub event: AgenticEvent, pub priority: AgenticEventPriority, pub timestamp: SystemTime, + /// Absent on old snapshots; defaults to [`AgenticEventOrigin::NativeRuntime`]. + #[serde(default)] + pub origin: AgenticEventOrigin, } impl PartialEq for AgenticEventEnvelope { @@ -623,11 +717,20 @@ impl Ord for AgenticEventEnvelope { impl AgenticEventEnvelope { pub fn new(event: AgenticEvent, priority: AgenticEventPriority) -> Self { + Self::new_with_origin(event, priority, AgenticEventOrigin::NativeRuntime) + } + + pub fn new_with_origin( + event: AgenticEvent, + priority: AgenticEventPriority, + origin: AgenticEventOrigin, + ) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), event, priority, timestamp: SystemTime::now(), + origin, } } } @@ -664,7 +767,11 @@ impl AgenticEvent { | Self::UserSteeringInjected { session_id, .. } | Self::DeepReviewQueueStateChanged { session_id, .. } | Self::SessionModelAutoMigrated { session_id, .. } - | Self::SessionReasoningPresetAutoCleared { session_id, .. } => Some(session_id), + | Self::SessionReasoningPresetAutoCleared { session_id, .. } + | Self::AcpContextUsageUpdated { session_id, .. } + | Self::AcpAvailableCommandsUpdated { session_id, .. } + | Self::AcpPlanUpdated { session_id, .. } + | Self::AcpSessionOptionsChanged { session_id, .. } => Some(session_id), Self::SystemError { session_id, .. } => session_id.as_deref(), } } @@ -689,7 +796,9 @@ impl AgenticEvent { | Self::ThinkingChunk { turn_id, .. } | Self::ToolEvent { turn_id, .. } | Self::DeepReviewQueueStateChanged { turn_id, .. } - | Self::UserSteeringInjected { turn_id, .. } => Some(turn_id), + | Self::UserSteeringInjected { turn_id, .. } + | Self::AcpContextUsageUpdated { turn_id, .. } + | Self::AcpPlanUpdated { turn_id, .. } => Some(turn_id), _ => None, } } @@ -726,7 +835,11 @@ impl AgenticEvent { | Self::ContextCompressionStarted { .. } | Self::ThreadGoalUpdated { .. } | Self::UserSteeringInjected { .. } - | Self::ContextCompressionCompleted { .. } => AgenticEventPriority::Normal, + | Self::ContextCompressionCompleted { .. } + | Self::AcpContextUsageUpdated { .. } + | Self::AcpAvailableCommandsUpdated { .. } + | Self::AcpPlanUpdated { .. } + | Self::AcpSessionOptionsChanged { .. } => AgenticEventPriority::Normal, Self::ToolEvent { tool_event, .. } => tool_event.default_priority(), @@ -1080,4 +1193,36 @@ mod tests { assert_eq!(serialized["type"], "SessionReasoningPresetAutoCleared"); assert_eq!(serialized["previous_preset_id"], "high"); } + + #[test] + fn envelope_without_origin_deserializes_as_native_runtime() { + let event = AgenticEvent::SessionStateChanged { + session_id: "session-1".to_string(), + new_state: "idle".to_string(), + }; + let mut value = + serde_json::to_value(AgenticEventEnvelope::new(event, AgenticEventPriority::High)) + .expect("serialize envelope"); + value.as_object_mut().expect("object").remove("origin"); + + let envelope: AgenticEventEnvelope = + serde_json::from_value(value).expect("legacy envelope"); + assert_eq!(envelope.origin, AgenticEventOrigin::NativeRuntime); + } + + #[test] + fn envelope_preserves_explicit_external_acp_origin() { + let envelope = AgenticEventEnvelope::new_with_origin( + AgenticEvent::SessionStateChanged { + session_id: "session-1".to_string(), + new_state: "idle".to_string(), + }, + AgenticEventPriority::High, + AgenticEventOrigin::ExternalAcp, + ); + let round_trip: AgenticEventEnvelope = + serde_json::from_value(serde_json::to_value(&envelope).expect("serialize")) + .expect("deserialize"); + assert_eq!(round_trip.origin, AgenticEventOrigin::ExternalAcp); + } } diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index 52b2d0e603..940546e5bd 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -4,8 +4,8 @@ //! transports. Concrete delivery adapters should only emit the projected //! envelope. -use crate::AgenticEvent; -use serde_json::{json, Value}; +use crate::{AgenticEvent, ModelRoundIdentity}; +use serde_json::{json, Map, Value}; #[derive(Debug, Clone, PartialEq)] pub struct AgenticFrontendEvent { @@ -124,20 +124,50 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Some(AgenticFrontendEvent::new( - "agentic://model-round-started", - json!({ + identity, + render_hints, + } => { + let mut payload = json!({ "sessionId": session_id, "turnId": turn_id, "roundId": round_id, "roundGroupId": round_group_id, "roundIndex": round_index, - "modelConfigId": model_config_id, - "effectiveModelName": effective_model_name, - }), - )), + }); + if let Some(render_hints) = render_hints { + payload["renderHints"] = json!(render_hints); + } + match identity { + ModelRoundIdentity::Native { + model_config_id, + effective_model_name, + } => { + payload["modelConfigId"] = json!(model_config_id); + payload["effectiveModelName"] = json!(effective_model_name); + } + ModelRoundIdentity::External { + provider, + client_id, + model_id, + display_name, + } => { + let mut external = Map::new(); + external.insert("provider".to_string(), json!(provider)); + external.insert("clientId".to_string(), json!(client_id)); + if let Some(model_id) = model_id { + external.insert("modelId".to_string(), json!(model_id)); + } + if let Some(display_name) = display_name { + external.insert("displayName".to_string(), json!(display_name)); + } + payload["externalModel"] = Value::Object(external); + } + } + Some(AgenticFrontendEvent::new( + "agentic://model-round-started", + payload, + )) + } AgenticEvent::TextChunk { session_id, turn_id, @@ -497,6 +527,60 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Some(AgenticFrontendEvent::new( + "agentic://acp-context-usage-updated", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "clientId": client_id, + "used": used, + "size": size, + "cost": cost, + }), + )), + AgenticEvent::AcpAvailableCommandsUpdated { + session_id, + client_id, + commands, + } => Some(AgenticFrontendEvent::new( + "agentic://acp-available-commands-updated", + json!({ + "sessionId": session_id, + "clientId": client_id, + "commands": commands, + }), + )), + AgenticEvent::AcpPlanUpdated { + session_id, + turn_id, + client_id, + entries, + } => Some(AgenticFrontendEvent::new( + "agentic://acp-plan-updated", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "clientId": client_id, + "entries": entries, + }), + )), + AgenticEvent::AcpSessionOptionsChanged { + session_id, + client_id, + } => Some(AgenticFrontendEvent::new( + "agentic://acp-session-options-changed", + json!({ + "sessionId": session_id, + "clientId": client_id, + }), + )), AgenticEvent::UserSteeringInjected { session_id, turn_id, @@ -672,13 +756,52 @@ mod tests { round_id: "round-1".to_string(), round_group_id: None, round_index: 0, - model_config_id: "model-config".to_string(), - effective_model_name: "provider-model".to_string(), + identity: ModelRoundIdentity::Native { + model_config_id: "model-config".to_string(), + effective_model_name: "provider-model".to_string(), + }, + render_hints: None, }) .expect("projected"); assert_eq!(projected.payload["modelConfigId"], "model-config"); assert_eq!(projected.payload["effectiveModelName"], "provider-model"); + assert!(projected.payload.get("externalModel").is_none()); + assert!(projected.payload.get("renderHints").is_none()); + } + + #[test] + fn model_round_started_projects_external_identity_without_native_keys() { + let projected = project_agentic_frontend_event(AgenticEvent::ModelRoundStarted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + round_group_id: None, + round_index: 0, + identity: ModelRoundIdentity::External { + provider: "acp".to_string(), + client_id: "gemini".to_string(), + model_id: Some("gemini-2.5".to_string()), + display_name: None, + }, + render_hints: Some(crate::ModelRoundRenderHints { + disable_explore_grouping: true, + }), + }) + .expect("projected"); + + assert!(projected.payload.get("modelConfigId").is_none()); + assert!(projected.payload.get("effectiveModelName").is_none()); + assert_eq!(projected.payload["externalModel"]["provider"], "acp"); + assert_eq!(projected.payload["externalModel"]["clientId"], "gemini"); + assert_eq!(projected.payload["externalModel"]["modelId"], "gemini-2.5"); + assert!(projected.payload["externalModel"] + .get("displayName") + .is_none()); + assert_eq!( + projected.payload["renderHints"]["disableExploreGrouping"], + true + ); } #[test] diff --git a/src/crates/contracts/events/src/lib.rs b/src/crates/contracts/events/src/lib.rs index 54a2d35aed..061519aef9 100644 --- a/src/crates/contracts/events/src/lib.rs +++ b/src/crates/contracts/events/src/lib.rs @@ -13,9 +13,11 @@ pub mod speech; pub mod types; pub use agentic::{ - AgenticEvent, AgenticEventEnvelope, AgenticEventPriority, DeepReviewQueueReason, - DeepReviewQueueState, DeepReviewQueueStatus, ModelRoundAttemptDiagnostic, - ModelRoundAttemptToolDiagnostic, SubagentParentInfo, ToolEventData, ToolEventIdentity, + AcpAvailableCommandFact, AcpPlanEntryFact, AgenticEvent, AgenticEventEnvelope, + AgenticEventOrigin, AgenticEventPriority, DeepReviewQueueReason, DeepReviewQueueState, + DeepReviewQueueStatus, ModelRoundAttemptDiagnostic, ModelRoundAttemptToolDiagnostic, + ModelRoundIdentity, ModelRoundRenderHints, SubagentParentInfo, ToolEventData, + ToolEventIdentity, }; pub use backend::{ BackgroundCommandLifecycleInfo, ToolExecutionCompletedInfo, ToolExecutionErrorInfo, diff --git a/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs b/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs index aeeca21363..c64309c040 100644 --- a/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs +++ b/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs @@ -94,6 +94,43 @@ pub struct RemoteWorkspaceUpdate { pub remote_ssh_host: Option, } +/// Remote-visible session owner. This is not [`bitfun_core_types::SessionKind`] +/// (Standard/Subagent); it distinguishes native Runtime sessions from +/// externally projected ACP sessions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum RemoteSessionKind { + /// Missing or unreadable on an old record. Never treat this as native. + #[default] + Unknown, + Native, + Acp, +} + +impl RemoteSessionKind { + pub const fn as_wire_str(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::Native => "native", + Self::Acp => "acp", + } + } + + /// Classify a session we persist. ACP is tagged with `provider=acp`; + /// everything else we own is native. Missing *wire* `session_kind` still + /// deserializes as [`Self::Unknown`] via serde default. + pub fn from_persisted_provider(provider: Option<&str>) -> Self { + match provider { + Some("acp") => Self::Acp, + _ => Self::Native, + } + } +} + +/// Capability id advertised on remote session metadata and negotiated in +/// command/response, never by package version equality. +pub const REMOTE_CAPABILITY_ACP_REMOTE_CONTROL: &str = "acp_remote_control"; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoteSessionMetadata { @@ -103,6 +140,15 @@ pub struct RemoteSessionMetadata { pub created_at_ms: u64, pub last_active_at_ms: u64, pub turn_count: usize, + /// Absent on old records; defaults to [`RemoteSessionKind::Unknown`]. + #[serde(default, skip_serializing_if = "is_unknown_remote_session_kind")] + pub session_kind: RemoteSessionKind, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, +} + +fn is_unknown_remote_session_kind(kind: &RemoteSessionKind) -> bool { + matches!(kind, RemoteSessionKind::Unknown) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -205,6 +251,8 @@ mod tests { created_at_ms: 10, last_active_at_ms: 20, turn_count: 3, + session_kind: RemoteSessionKind::Native, + capabilities: Vec::new(), }; assert_eq!(workspace.kind.as_wire_str(), "remote"); @@ -212,6 +260,30 @@ mod tests { assert_eq!(workspace.remote_connection_id.as_deref(), Some("conn-1")); assert_eq!(workspace.remote_ssh_host.as_deref(), Some("host-1")); assert_eq!(session.turn_count, 3); + assert_eq!(session.session_kind, RemoteSessionKind::Native); + } + + #[test] + fn remote_session_kind_defaults_to_unknown_on_legacy_payloads() { + let session: RemoteSessionMetadata = serde_json::from_value(serde_json::json!({ + "sessionId": "legacy", + "name": "old", + "agentType": "agentic", + "createdAtMs": 1, + "lastActiveAtMs": 2, + "turnCount": 0 + })) + .expect("legacy session metadata should deserialize"); + assert_eq!(session.session_kind, RemoteSessionKind::Unknown); + assert!(session.capabilities.is_empty()); + assert_eq!( + RemoteSessionKind::from_persisted_provider(Some("acp")), + RemoteSessionKind::Acp + ); + assert_eq!( + RemoteSessionKind::from_persisted_provider(None), + RemoteSessionKind::Native + ); } #[test] diff --git a/src/crates/execution/agent-runtime/src/event_queue.rs b/src/crates/execution/agent-runtime/src/event_queue.rs index b62aefe225..1701dcafc4 100644 --- a/src/crates/execution/agent-runtime/src/event_queue.rs +++ b/src/crates/execution/agent-runtime/src/event_queue.rs @@ -3,7 +3,8 @@ use crate::event_bus::EventBusResult; use bitfun_agent_stream::StreamEventSink; use bitfun_events::{ - AgenticEvent, AgenticEventEnvelope as EventEnvelope, AgenticEventPriority as EventPriority, + AgenticEvent, AgenticEventEnvelope as EventEnvelope, AgenticEventOrigin, + AgenticEventPriority as EventPriority, }; use log::{debug, trace, warn}; use std::collections::{BinaryHeap, HashMap}; @@ -107,6 +108,14 @@ pub struct QueueStats { pub total_processed: u64, } +/// How long a producer may wait for the legacy dequeue consumer to take a +/// fenced event. Without this, an unbounded publisher plus a stalled dequeue +/// loop turns a lost event into unbounded memory growth. +#[cfg(not(test))] +const LEGACY_DEQUEUE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +#[cfg(test)] +const LEGACY_DEQUEUE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50); + /// Completion handle for an event that must enter the legacy dequeue stream /// before a producer may publish dependent events. pub struct LegacyDequeueAck { @@ -115,9 +124,15 @@ pub struct LegacyDequeueAck { impl LegacyDequeueAck { pub async fn wait(self) -> EventBusResult<()> { - self.receiver - .await - .map_err(|_| crate::event_bus::EventBusError::subscriber("legacy dequeue fence closed")) + match tokio::time::timeout(LEGACY_DEQUEUE_ACK_TIMEOUT, self.receiver).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => Err(crate::event_bus::EventBusError::subscriber( + "legacy dequeue fence closed", + )), + Err(_) => Err(crate::event_bus::EventBusError::subscriber( + "legacy dequeue fence timed out before the event was dequeued", + )), + } } } @@ -261,7 +276,19 @@ impl EventQueue { event: AgenticEvent, priority: Option, ) -> EventBusResult { - self.enqueue_internal(event, priority, LegacyQueuePolicy::BestEffort) + self.enqueue_with_origin(event, priority, AgenticEventOrigin::NativeRuntime) + .await + } + + /// Enqueue event with an explicit origin. Existing call sites keep + /// [`enqueue`], which defaults to [`AgenticEventOrigin::NativeRuntime`]. + pub async fn enqueue_with_origin( + &self, + event: AgenticEvent, + priority: Option, + origin: AgenticEventOrigin, + ) -> EventBusResult { + self.enqueue_internal(event, priority, LegacyQueuePolicy::BestEffort, origin) .await .map(|(event_id, _)| event_id) } @@ -276,9 +303,28 @@ impl EventQueue { &self, event: AgenticEvent, priority: Option, + ) -> EventBusResult<(String, LegacyDequeueAck)> { + self.enqueue_with_legacy_dequeue_ack_with_origin( + event, + priority, + AgenticEventOrigin::NativeRuntime, + ) + .await + } + + pub async fn enqueue_with_legacy_dequeue_ack_with_origin( + &self, + event: AgenticEvent, + priority: Option, + origin: AgenticEventOrigin, ) -> EventBusResult<(String, LegacyDequeueAck)> { let (event_id, ack) = self - .enqueue_internal(event, priority, LegacyQueuePolicy::RequireImmediateAck) + .enqueue_internal( + event, + priority, + LegacyQueuePolicy::RequireImmediateAck, + origin, + ) .await?; Ok(( event_id, @@ -298,9 +344,28 @@ impl EventQueue { event: AgenticEvent, priority: Option, ) -> EventBusResult { - self.enqueue_internal(event, priority, LegacyQueuePolicy::AuthoritativeControl) - .await - .map(|(event_id, _)| event_id) + self.enqueue_with_guaranteed_legacy_storage_with_origin( + event, + priority, + AgenticEventOrigin::NativeRuntime, + ) + .await + } + + pub async fn enqueue_with_guaranteed_legacy_storage_with_origin( + &self, + event: AgenticEvent, + priority: Option, + origin: AgenticEventOrigin, + ) -> EventBusResult { + self.enqueue_internal( + event, + priority, + LegacyQueuePolicy::AuthoritativeControl, + origin, + ) + .await + .map(|(event_id, _)| event_id) } async fn enqueue_internal( @@ -308,9 +373,10 @@ impl EventQueue { event: AgenticEvent, priority: Option, legacy_policy: LegacyQueuePolicy, + origin: AgenticEventOrigin, ) -> EventBusResult<(String, Option)> { let priority = priority.unwrap_or_else(|| event.default_priority()); - let envelope = EventEnvelope::new(event, priority); + let envelope = EventEnvelope::new_with_origin(event, priority, origin); let event_id = envelope.id.clone(); let require_legacy_ack = matches!(legacy_policy, LegacyQueuePolicy::RequireImmediateAck); let (mut ack_sender, ack) = if require_legacy_ack { @@ -1159,4 +1225,52 @@ mod tests { assert!(queue.session_broadcasts.read().unwrap().is_empty()); } + + #[tokio::test] + async fn legacy_dequeue_ack_times_out_if_never_dequeued() { + let queue = EventQueue::new(EventQueueConfig { + max_queue_size: 8, + batch_size: 8, + }); + let (_, ack) = queue + .enqueue_with_legacy_dequeue_ack( + AgenticEvent::DialogTurnCancelled { + session_id: "session".to_string(), + turn_id: "turn".to_string(), + }, + Some(AgenticEventPriority::Normal), + ) + .await + .expect("fence should enqueue"); + let error = ack.wait().await.expect_err("must not hang forever"); + assert!( + error.to_string().contains("timed out"), + "timeout must fail loud, got {error}" + ); + assert_eq!(queue.len().await, 1); + } + + #[tokio::test] + async fn enqueue_with_origin_preserves_external_acp() { + use bitfun_events::AgenticEventOrigin; + + let queue = EventQueue::new(EventQueueConfig { + max_queue_size: 8, + batch_size: 8, + }); + queue + .enqueue_with_origin( + AgenticEvent::SessionStateChanged { + session_id: "session".to_string(), + new_state: "idle".to_string(), + }, + None, + AgenticEventOrigin::ExternalAcp, + ) + .await + .expect("enqueue"); + let batch = queue.dequeue_configured_batch().await; + assert_eq!(batch.len(), 1); + assert_eq!(batch[0].origin, AgenticEventOrigin::ExternalAcp); + } } diff --git a/src/crates/execution/agent-runtime/src/event_router.rs b/src/crates/execution/agent-runtime/src/event_router.rs index e49a5996a0..ed61a3d9db 100644 --- a/src/crates/execution/agent-runtime/src/event_router.rs +++ b/src/crates/execution/agent-runtime/src/event_router.rs @@ -12,6 +12,12 @@ use std::sync::Arc; #[async_trait::async_trait] pub trait EventSubscriber: Send + Sync + 'static { async fn on_event(&self, event: &AgenticEvent) -> EventSubscriberResult; + + /// Default forwards to [`Self::on_event`]. Override only when origin + /// isolation is required (for example Cron). + async fn on_envelope(&self, envelope: &EventEnvelope) -> EventSubscriberResult { + self.on_event(&envelope.event).await + } } /// Event router @@ -35,8 +41,6 @@ impl EventRouter { /// /// Note: frontend events are sent directly using lib.rs:emit_to_frontend(), not through this router pub async fn route(&self, envelope: EventEnvelope) -> EventBusResult<()> { - let event = &envelope.event; - // First collect subscribers list (avoid holding DashMap iterator across await points) let subscribers: Vec<(String, Arc)> = self .internal_subscribers @@ -58,7 +62,7 @@ impl EventRouter { // Send to all internal subscribers for (subscriber_id, subscriber) in subscribers { - if let Err(e) = subscriber.on_event(event).await { + if let Err(e) = subscriber.on_envelope(&envelope).await { warn!( "Internal subscriber {} failed to process event: {}", subscriber_id, e @@ -79,9 +83,8 @@ impl EventRouter { .collect(); for envelope in envelopes { - let event = &envelope.event; for (subscriber_id, subscriber) in &subscribers { - if let Err(e) = subscriber.on_event(event).await { + if let Err(e) = subscriber.on_envelope(&envelope).await { warn!( "Internal subscriber {} failed to process event: {}", subscriber_id, e @@ -119,3 +122,76 @@ impl Default for EventRouter { Self::new() } } + +#[cfg(test)] +mod tests { + use super::{EventRouter, EventSubscriber}; + use crate::event_bus::EventSubscriberResult; + use bitfun_events::{ + AgenticEvent, AgenticEventEnvelope, AgenticEventOrigin, AgenticEventPriority, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + struct RecordingSubscriber { + events: AtomicUsize, + external_envelopes: AtomicUsize, + } + + #[async_trait::async_trait] + impl EventSubscriber for RecordingSubscriber { + async fn on_event(&self, _event: &AgenticEvent) -> EventSubscriberResult { + self.events.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn on_envelope(&self, envelope: &AgenticEventEnvelope) -> EventSubscriberResult { + if envelope.origin == AgenticEventOrigin::ExternalAcp { + self.external_envelopes.fetch_add(1, Ordering::SeqCst); + } + self.on_event(&envelope.event).await + } + } + + struct DefaultForwardSubscriber { + events: AtomicUsize, + } + + #[async_trait::async_trait] + impl EventSubscriber for DefaultForwardSubscriber { + async fn on_event(&self, _event: &AgenticEvent) -> EventSubscriberResult { + self.events.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + #[tokio::test] + async fn route_delivers_external_origin_to_envelope_override() { + let router = EventRouter::new(); + let recording = Arc::new(RecordingSubscriber { + events: AtomicUsize::new(0), + external_envelopes: AtomicUsize::new(0), + }); + let forwarding = Arc::new(DefaultForwardSubscriber { + events: AtomicUsize::new(0), + }); + router.subscribe_internal("recording".to_string(), recording.clone()); + router.subscribe_internal("forwarding".to_string(), forwarding.clone()); + + router + .route(AgenticEventEnvelope::new_with_origin( + AgenticEvent::SessionStateChanged { + session_id: "session-1".to_string(), + new_state: "idle".to_string(), + }, + AgenticEventPriority::High, + AgenticEventOrigin::ExternalAcp, + )) + .await + .expect("route"); + + assert_eq!(recording.events.load(Ordering::SeqCst), 1); + assert_eq!(recording.external_envelopes.load(Ordering::SeqCst), 1); + assert_eq!(forwarding.events.load(Ordering::SeqCst), 1); + } +} diff --git a/src/crates/execution/agent-runtime/src/session_event_journal.rs b/src/crates/execution/agent-runtime/src/session_event_journal.rs index 2de97d2731..2f86d5c960 100644 --- a/src/crates/execution/agent-runtime/src/session_event_journal.rs +++ b/src/crates/execution/agent-runtime/src/session_event_journal.rs @@ -406,7 +406,9 @@ fn event_turn_id(event: &AgenticEvent) -> Option<&str> { | AgenticEvent::ThinkingChunk { turn_id, .. } | AgenticEvent::ToolEvent { turn_id, .. } | AgenticEvent::DeepReviewQueueStateChanged { turn_id, .. } - | AgenticEvent::UserSteeringInjected { turn_id, .. } => Some(turn_id), + | AgenticEvent::UserSteeringInjected { turn_id, .. } + | AgenticEvent::AcpContextUsageUpdated { turn_id, .. } + | AgenticEvent::AcpPlanUpdated { turn_id, .. } => Some(turn_id), AgenticEvent::SubagentSessionLinked { subagent_dialog_turn_id, .. diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index c4835acabb..7ac1b3837c 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -44,6 +44,7 @@ use super::config::{ AcpClientRequirementProbe, AcpClientStatus, RemoteAcpClientRequirementSnapshot, }; use super::dsh_profile::{ensure_bundled_profile, ensure_bundled_profile_remote}; +use super::permission_ids::new_acp_permission_id; use super::remote_capability_store::RemoteAcpCapabilityStore; use super::remote_session::{preferred_resume_strategies, AcpRemoteSessionStrategy}; use super::remote_shell::{remote_user_shell_command, render_remote_env_assignments, shell_escape}; @@ -53,8 +54,8 @@ use super::requirements::{ probe_remote_executable, probe_remote_npx_adapter, resolve_configured_command, }; use super::session_options::{ - model_config_id, session_options_from_state, AcpAvailableCommand, AcpSessionContextUsage, - AcpSessionOptions, + model_config_id, session_options_from_state, AcpAvailableCommand, AcpPlanEntry, + AcpSessionContextUsage, AcpSessionOptions, }; use super::session_persistence::AcpSessionPersistence; pub use super::session_persistence::CreateAcpFlowSessionRecordResponse; @@ -151,6 +152,22 @@ pub struct AcpClientService { clients: DashMap>, pending_permissions: DashMap, session_permission_modes: DashMap, + /// Optional Desktop-owned observation sink. When set, permission requests + /// go through this port instead of a hand-written Custom event emit. + permission_observer: std::sync::RwLock>>, +} + +/// Desktop (or other host) observation surface for ACP permission requests. +pub trait AcpPermissionObserver: Send + Sync { + fn on_permission_requested( + &self, + permission_id: &str, + session_id: &str, + tool_call: &serde_json::Value, + options: &serde_json::Value, + timeout: Duration, + ); + fn on_permission_resolved(&self, permission_id: &str); } struct PendingPermission { @@ -177,6 +194,9 @@ struct AcpRemoteSession { config_options: Vec, context_usage: Option, available_commands: Vec, + available_commands_version: u64, + plan_entries: Vec, + plan_version: u64, discard_pending_updates_before_next_prompt: bool, } @@ -206,6 +226,9 @@ impl AcpRemoteSession { config_options: Vec::new(), context_usage: None, available_commands: Vec::new(), + available_commands_version: 0, + plan_entries: Vec::new(), + plan_version: 0, discard_pending_updates_before_next_prompt: false, } } @@ -227,9 +250,17 @@ impl AcpClientService { clients: DashMap::new(), pending_permissions: DashMap::new(), session_permission_modes: DashMap::new(), + permission_observer: std::sync::RwLock::new(None), })) } + pub fn set_permission_observer(&self, observer: Arc) { + *self + .permission_observer + .write() + .expect("ACP permission observer") = Some(observer); + } + pub async fn create_flow_session_record( &self, session_storage_path: &Path, @@ -900,6 +931,21 @@ impl AcpClientService { &self, request: SubmitAcpPermissionResponseRequest, ) -> BitFunResult { + let option_id = { + let Some(pending) = self.pending_permissions.get(&request.permission_id) else { + return Err(BitFunError::NotFound(format!( + "ACP permission request not found: {}", + request.permission_id + ))); + }; + resolve_submitted_permission_option_id( + &pending.options, + request.option_id.as_deref(), + request.approve, + ) + .map_err(BitFunError::validation)? + }; + let Some((_, pending)) = self.pending_permissions.remove(&request.permission_id) else { return Err(BitFunError::NotFound(format!( "ACP permission request not found: {}", @@ -907,9 +953,15 @@ impl AcpClientService { ))); }; - let option_id = request - .option_id - .unwrap_or_else(|| select_permission_option_id(&pending.options, request.approve)); + if let Some(observer) = self + .permission_observer + .read() + .expect("ACP permission observer") + .as_ref() + { + observer.on_permission_resolved(&request.permission_id); + } + let response = RequestPermissionResponse::new(RequestPermissionOutcome::Selected( SelectedPermissionOutcome::new(option_id), )); @@ -962,7 +1014,41 @@ impl AcpClientService { remote_connection_id: Option, session_storage_path: Option, bitfun_session_id: String, - ) -> BitFunResult> { + ) -> BitFunResult<(Vec, u64)> { + let resolved = self + .resolve_or_create_client_session( + client_id, + workspace_path, + remote_connection_id.as_deref(), + &bitfun_session_id, + ) + .await?; + + let mut session = resolved.session.lock().await; + self.ensure_remote_session( + &resolved.client, + &resolved.session_key, + &resolved.cwd, + &bitfun_session_id, + session_storage_path.as_deref(), + &mut session, + ) + .await?; + drain_pending_session_metadata_updates(&mut session).await?; + Ok(( + session.available_commands.clone(), + session.available_commands_version, + )) + } + + pub async fn get_session_plan( + self: &Arc, + client_id: &str, + workspace_path: Option, + remote_connection_id: Option, + session_storage_path: Option, + bitfun_session_id: String, + ) -> BitFunResult<(Vec, u64)> { let resolved = self .resolve_or_create_client_session( client_id, @@ -983,7 +1069,11 @@ impl AcpClientService { ) .await?; drain_pending_session_metadata_updates(&mut session).await?; - Ok(session.available_commands.clone()) + Ok((session.plan_entries.clone(), session.plan_version)) + } + + pub fn has_pending_permission(&self, permission_id: &str) -> bool { + self.pending_permissions.contains_key(permission_id) } pub async fn set_session_model( @@ -1670,7 +1760,7 @@ impl AcpClientService { AcpClientPermissionMode::Ask => {} } - let permission_id = format!("acp_permission_{}", uuid::Uuid::new_v4()); + let permission_id = new_acp_permission_id(); let (tx, rx) = oneshot::channel(); self.pending_permissions.insert( permission_id.clone(), @@ -1680,19 +1770,35 @@ impl AcpClientService { }, ); + let tool_call = serde_json::to_value(&request.tool_call).unwrap_or(json!({})); + let options = serde_json::to_value(&request.options).unwrap_or(json!([])); let payload = json!({ "permissionId": permission_id, "sessionId": session_id, - "toolCall": request.tool_call, - "options": request.options, + "toolCall": tool_call, + "options": options, }); - if let Err(error) = emit_global_event(BackendEvent::Custom { + let observer = self + .permission_observer + .read() + .expect("ACP permission observer") + .clone(); + if let Some(observer) = observer { + observer.on_permission_requested( + &permission_id, + &session_id, + &tool_call, + &options, + PERMISSION_TIMEOUT, + ); + } else if let Err(error) = emit_global_event(BackendEvent::Custom { event_name: "backend-event-acppermissionrequest".to_string(), payload, }) .await { + // CLI / hosts without a Desktop mailbox keep the legacy emit path. warn!("Failed to emit ACP permission request: {}", error); } @@ -1703,6 +1809,14 @@ impl AcpClientService { )), Err(_) => { self.pending_permissions.remove(&permission_id); + if let Some(observer) = self + .permission_observer + .read() + .expect("ACP permission observer") + .as_ref() + { + observer.on_permission_resolved(&permission_id); + } Ok(RequestPermissionResponse::new( RequestPermissionOutcome::Cancelled, )) @@ -2520,6 +2634,18 @@ fn update_session_from_events(session: &mut AcpRemoteSession, events: &[AcpClien update_session_context_usage(session, events); update_session_available_commands(session, events); update_session_config_options(session, events); + update_session_plan(session, events); +} + +fn update_session_plan(session: &mut AcpRemoteSession, events: &[AcpClientStreamEvent]) { + let Some(entries) = events.iter().rev().find_map(|event| match event { + AcpClientStreamEvent::PlanUpdated(entries) => Some(entries.clone()), + _ => None, + }) else { + return; + }; + session.plan_entries = entries; + session.plan_version = session.plan_version.saturating_add(1); } fn update_session_context_usage(session: &mut AcpRemoteSession, events: &[AcpClientStreamEvent]) { @@ -2545,6 +2671,7 @@ fn update_session_available_commands( }; session.available_commands = commands; + session.available_commands_version = session.available_commands_version.saturating_add(1); } fn update_session_config_options(session: &mut AcpRemoteSession, events: &[AcpClientStreamEvent]) { @@ -2717,6 +2844,27 @@ fn select_permission_by_kind( )) } +fn resolve_submitted_permission_option_id( + options: &[PermissionOption], + option_id: Option<&str>, + approve: bool, +) -> Result { + match option_id { + Some(option_id) => { + let allowed = options + .iter() + .any(|option| option.option_id.to_string() == option_id); + if !allowed { + return Err(format!( + "ACP permission option_id is not in the pending options: option_id={option_id}" + )); + } + Ok(option_id.to_string()) + } + None => Ok(select_permission_option_id(options, approve)), + } +} + fn select_permission_option_id(options: &[PermissionOption], approve: bool) -> String { let preferred_kinds = if approve { [ @@ -2797,6 +2945,22 @@ mod tests { assert_eq!(select_permission_option_id(&options, true), "yes-once"); } + #[test] + fn rejects_option_id_missing_from_pending_options() { + let options = vec![ + PermissionOption::new("deny", "Deny", PermissionOptionKind::RejectOnce), + PermissionOption::new("yes-once", "Allow", PermissionOptionKind::AllowOnce), + ]; + let error = resolve_submitted_permission_option_id(&options, Some("allow_always"), true) + .expect_err("unknown option must fail"); + assert!(error.contains("allow_always")); + assert_eq!( + resolve_submitted_permission_option_id(&options, Some("yes-once"), true) + .expect("known option"), + "yes-once" + ); + } + #[test] fn selects_actual_permission_option_id_for_rejection() { let options = vec![ diff --git a/src/crates/interfaces/acp/src/client/mod.rs b/src/crates/interfaces/acp/src/client/mod.rs index 086cb3b845..56ccd25d22 100644 --- a/src/crates/interfaces/acp/src/client/mod.rs +++ b/src/crates/interfaces/acp/src/client/mod.rs @@ -2,6 +2,7 @@ mod builtin_clients; mod config; mod dsh_profile; mod manager; +mod permission_ids; mod remote_capability_store; mod remote_session; mod remote_shell; @@ -18,10 +19,11 @@ pub use config::{ RemoteAcpClientRequirementSnapshot, }; pub use manager::{ - AcpClientPermissionResponse, AcpClientService, AcpSessionConfigValue, + AcpClientPermissionResponse, AcpClientService, AcpPermissionObserver, AcpSessionConfigValue, CreateAcpFlowSessionRecordResponse, SetAcpSessionConfigOptionRequest, SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; +pub use permission_ids::{is_acp_permission_id, new_acp_permission_id, ACP_PERMISSION_ID_PREFIX}; pub use session_options::{ AcpAvailableCommand, AcpPlanEntry, AcpSessionConfigKind, AcpSessionConfigOption, AcpSessionConfigSelectOption, AcpSessionContextUsage, AcpSessionModelOption, AcpSessionOptions, diff --git a/src/crates/interfaces/acp/src/client/permission_ids.rs b/src/crates/interfaces/acp/src/client/permission_ids.rs new file mode 100644 index 0000000000..f57c1611af --- /dev/null +++ b/src/crates/interfaces/acp/src/client/permission_ids.rs @@ -0,0 +1,14 @@ +//! Shared ACP permission id helpers. +//! +//! Keep the minted prefix and remote detectors in one place so Desktop remote +//! control and the ACP client cannot drift apart. + +pub const ACP_PERMISSION_ID_PREFIX: &str = "acp_permission_"; + +pub fn is_acp_permission_id(permission_id: &str) -> bool { + permission_id.starts_with(ACP_PERMISSION_ID_PREFIX) +} + +pub fn new_acp_permission_id() -> String { + format!("{ACP_PERMISSION_ID_PREFIX}{}", uuid::Uuid::new_v4()) +} diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index e3a5f0aece..0a48d7556a 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -8,6 +8,8 @@ //! session/runtime hosts stay in `bitfun-core` until their ports are explicit. pub mod account; +pub mod acp_control; +pub mod acp_permission_mailbox; pub mod bot; mod chat_projection; pub mod device; @@ -24,17 +26,212 @@ pub mod session_store; pub mod sync_state; use bitfun_core_types::{ModelsDevReasoningCatalog, ProviderCatalog, ReasoningCatalogProjection}; -use bitfun_events::AgenticEvent; +use bitfun_events::{AcpAvailableCommandFact, AcpPlanEntryFact, AgenticEvent}; use bitfun_runtime_ports::{ AgentInputAttachment, AgentSessionCreateRequest, AgentSubmissionRequest, AgentSubmissionSource, RemoteControlStateSnapshot, }; pub use bitfun_runtime_ports::{ RemoteAssistantWorkspaceFacts, RemoteFileChunkRange, RemoteInitialSyncRuntimeHost, - RemoteProjectionPort, RemoteRecentWorkspaceFacts, RemoteSessionMetadata, + RemoteProjectionPort, RemoteRecentWorkspaceFacts, RemoteSessionKind, RemoteSessionMetadata, RemoteSessionWorkspaceIdentity, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, RemoteWorkspaceFileContent, RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, RemoteWorkspacePort, RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, + REMOTE_CAPABILITY_ACP_REMOTE_CONTROL, +}; + +/// Structured error code for a remote command or session that the host cannot +/// serve. Clients must not treat this as a native `agentic` fallback. +pub const UNSUPPORTED_REMOTE_CAPABILITY: &str = "unsupported_remote_capability"; + +/// Fail-loud copy when a native `send_message` targets an ACP session. +pub const ACP_SESSION_REQUIRES_ACP_CONTROL_MESSAGE: &str = + "ACP session requires ACP control capability"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteSessionControlFacts { + pub session_kind: RemoteSessionKind, + pub capabilities: Vec, +} + +impl RemoteSessionControlFacts { + pub fn unknown() -> Self { + Self { + session_kind: RemoteSessionKind::Unknown, + capabilities: Vec::new(), + } + } + + pub fn native() -> Self { + Self { + session_kind: RemoteSessionKind::Native, + capabilities: Vec::new(), + } + } + + pub fn acp(capabilities: Vec) -> Self { + Self { + session_kind: RemoteSessionKind::Acp, + capabilities, + } + } + + pub fn has_capability(&self, capability: &str) -> bool { + self.capabilities.iter().any(|item| item == capability) + } +} + +/// Native `SendMessage` is never legal for ACP sessions. ACP control uses the +/// `Acp*` command family after `acp_remote_control` is advertised; capability +/// presence must not reopen the native dialog path. +pub fn reject_native_dialog_for_session( + facts: &RemoteSessionControlFacts, +) -> Option { + if facts.session_kind != RemoteSessionKind::Acp { + return None; + } + Some(RemoteUnsupportedCapability { + code: UNSUPPORTED_REMOTE_CAPABILITY.to_string(), + message: ACP_SESSION_REQUIRES_ACP_CONTROL_MESSAGE.to_string(), + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteUnsupportedCapability { + pub code: String, + pub message: String, +} + +impl RemoteUnsupportedCapability { + pub fn into_error_string(self) -> String { + format!("{}: {}", self.code, self.message) + } +} + +/// Structured error code when a known `acp_*` command name fails payload +/// deserialization (missing fields, wrong types). Distinct from +/// [`UNSUPPORTED_REMOTE_CAPABILITY`], which means the command name itself is +/// not part of the host's ACP family. +pub const INVALID_ACP_COMMAND_PARAMS: &str = "invalid_acp_command_params"; + +/// ACP remote-control command names recognized by the current wire enum. +/// Unknown `acp_*` names are unsupported; known names with bad payloads are +/// [`INVALID_ACP_COMMAND_PARAMS`]. +const KNOWN_ACP_REMOTE_COMMANDS: &[&str] = &[ + "acp_send_message", + "acp_cancel_turn", + "acp_get_options", + "acp_set_option", + "acp_get_commands", + "acp_get_plan", + "acp_permission_respond", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteCommandParseError { + Unsupported { + cmd: String, + code: String, + message: String, + }, + /// Known ACP command name, but the payload could not be deserialized. + InvalidAcpParams { + cmd: String, + code: String, + message: String, + }, + Invalid { + message: String, + }, +} + +impl RemoteCommandParseError { + pub fn unsupported_acp_command(cmd: impl Into) -> Self { + let cmd = cmd.into(); + Self::Unsupported { + message: format!("Host does not support ACP command `{cmd}`"), + cmd, + code: UNSUPPORTED_REMOTE_CAPABILITY.to_string(), + } + } + + pub fn invalid_acp_command_params(cmd: impl Into, detail: impl Into) -> Self { + let cmd = cmd.into(); + let detail = detail.into(); + Self::InvalidAcpParams { + message: format!("Invalid ACP command params for `{cmd}`: {detail}"), + cmd, + code: INVALID_ACP_COMMAND_PARAMS.to_string(), + } + } + + pub fn into_remote_response(self) -> RemoteResponse { + match self { + Self::Unsupported { + code, message, cmd, .. + } + | Self::InvalidAcpParams { + code, message, cmd, .. + } => remote_unsupported_response(code, format!("{message} (cmd={cmd})")), + Self::Invalid { message } => remote_error_response(message), + } + } +} + +/// Parse a remote command value. +/// +/// - Unknown `acp_*` command **names** → structured unsupported (not native send). +/// - Known `acp_*` names with bad payloads → [`INVALID_ACP_COMMAND_PARAMS`]. +/// - Other parse failures → plain invalid (no capability code). +pub fn parse_remote_command( + value: serde_json::Value, +) -> Result { + let cmd = value + .get("cmd") + .and_then(|item| item.as_str()) + .unwrap_or("") + .to_string(); + match serde_json::from_value::(value) { + Ok(command) => Ok(command), + Err(error) if cmd.starts_with("acp_") => { + if KNOWN_ACP_REMOTE_COMMANDS.contains(&cmd.as_str()) { + Err(RemoteCommandParseError::invalid_acp_command_params( + cmd, + error.to_string(), + )) + } else { + Err(RemoteCommandParseError::unsupported_acp_command(cmd)) + } + } + Err(error) => Err(RemoteCommandParseError::Invalid { + message: format!("parse command: {error}"), + }), + } +} + +pub fn remote_unsupported_response( + code: impl Into, + message: impl Into, +) -> RemoteResponse { + RemoteResponse::Error { + message: message.into(), + code: Some(code.into()), + } +} +pub use acp_control::{ + acp_cancel_response, acp_commands_response, acp_native_session_control_unsupported, + acp_native_tool_interaction_unsupported, acp_options_response, acp_permission_respond_response, + acp_permission_respond_unsupported, acp_plan_response, acp_send_response, + RemoteAcpCancelOutcome, RemoteAcpCancelRequest, RemoteAcpCommandsOutcome, + RemoteAcpControlError, RemoteAcpControlRuntimeHost, RemoteAcpGetCommandsRequest, + RemoteAcpGetOptionsRequest, RemoteAcpGetPlanRequest, RemoteAcpOptionsOutcome, + RemoteAcpPermissionRespondOutcome, RemoteAcpPermissionRespondRequest, RemoteAcpPlanOutcome, + RemoteAcpSendOutcome, RemoteAcpSendRequest, RemoteAcpSetOptionRequest, + RemoteRetryClassification, +}; +pub use acp_permission_mailbox::{ + acp_permission_mailbox, acp_permission_now_ms, install_acp_permission_mailbox, + sync_acp_permission_mailbox_into_tracker, AcpPermissionMailbox, AcpPermissionMailboxEntry, }; pub use chat_projection::{ agent_input_attachment_from_remote_image_context, project_remote_chat_user, @@ -468,6 +665,13 @@ pub trait RemoteDialogRuntimeHost: Send + Sync { fn generate_turn_id(&self) -> String; + /// Session kind/capability facts used to fail-loud on ACP sessions. + /// Default is unknown so older hosts do not silently claim native. + async fn remote_session_control(&self, session_id: &str) -> RemoteSessionControlFacts { + let _ = session_id; + RemoteSessionControlFacts::unknown() + } + async fn submit_dialog( &self, submission: RemoteDialogResolvedSubmission, @@ -508,6 +712,11 @@ where .map(|binding| binding.workspace_path.clone()), }); + let control = host.remote_session_control(&session_id).await; + if let Some(rejected) = reject_native_dialog_for_session(&control) { + return Err(rejected.into_error_string()); + } + let resolved_agent_type = resolve_remote_agent_type(agent_type.as_deref()).to_string(); let turn_id = turn_id.unwrap_or_else(|| host.generate_turn_id()); @@ -751,7 +960,10 @@ pub fn remote_file_content_response( size: content.size, } } - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, } } @@ -770,7 +982,10 @@ pub fn remote_file_chunk_response( mime_type: chunk.mime_type.to_string(), } } - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, } } @@ -783,7 +998,10 @@ pub fn remote_file_info_response( size: info.size, mime_type: info.mime_type.to_string(), }, - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, } } @@ -832,6 +1050,7 @@ where } _ => RemoteResponse::Error { message: "Unsupported remote workspace file command".to_string(), + code: None, }, } } @@ -851,10 +1070,25 @@ pub fn remote_dialog_submit_response( session_id, turn_id, }, - Err(message) => RemoteResponse::Error { message }, + Err(message) => remote_dialog_error_response(message), } } +pub fn remote_error_response(message: impl Into) -> RemoteResponse { + RemoteResponse::Error { + message: message.into(), + code: None, + } +} + +fn remote_dialog_error_response(message: impl Into) -> RemoteResponse { + let message = message.into(); + if let Some(stripped) = message.strip_prefix(&format!("{UNSUPPORTED_REMOTE_CAPABILITY}: ")) { + return remote_unsupported_response(UNSUPPORTED_REMOTE_CAPABILITY, stripped); + } + remote_error_response(message) +} + pub fn remote_task_cancel_response( session_id: impl Into, result: Result<(), String>, @@ -863,7 +1097,10 @@ pub fn remote_task_cancel_response( Ok(()) => RemoteResponse::TaskCancelled { session_id: session_id.into(), }, - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, } } @@ -877,14 +1114,20 @@ pub fn remote_interaction_accepted_response( action: action.into(), target_id: target_id.into(), }, - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, } } pub fn remote_answer_question_response(result: Result<(), String>) -> RemoteResponse { match result { Ok(()) => RemoteResponse::AnswerAccepted, - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, } } @@ -1002,6 +1245,8 @@ pub fn remote_session_info( message_count: metadata.turn_count, workspace_path: workspace_path.map(ToOwned::to_owned), workspace_name: workspace_name.map(ToOwned::to_owned), + session_kind: metadata.session_kind, + capabilities: metadata.capabilities.clone(), } } @@ -1106,6 +1351,7 @@ where } _ => RemoteResponse::Error { message: "Unknown workspace command".into(), + code: None, }, } } @@ -1250,7 +1496,8 @@ where else { return RemoteResponse::Error { message: "No workspace is open on the remote device; select a recent workspace or create one first".to_string(), - }; + code: None, + }; }; let workspace_path_str = workspace_path.to_string_lossy().to_string(); @@ -1289,7 +1536,10 @@ where page_offset, ) } - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, } } RemoteCommand::CreateSession { @@ -1313,7 +1563,12 @@ where let binding_workspace = if is_claw { match host.resolve_default_assistant_workspace_path().await { Ok(path) => Some(path), - Err(message) => return RemoteResponse::Error { message }, + Err(message) => { + return RemoteResponse::Error { + message, + code: None, + } + } } } else { workspace_path @@ -1330,6 +1585,7 @@ where } else { "No workspace is open on the remote device; select a recent workspace or create one first".to_string() }, + code: None, }; }; @@ -1345,13 +1601,19 @@ where ); match host.create_session(request).await { Ok(session_id) => remote_session_created_response(session_id), - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, } } RemoteCommand::GetModelCatalog { session_id } => { match host.load_model_catalog(session_id.as_deref()).await { Ok(catalog) => RemoteResponse::ModelCatalog { catalog }, - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, } } RemoteCommand::SetSessionModel { @@ -1367,11 +1629,17 @@ where .await { Ok(selection) => remote_session_model_updated_response(session_id.clone(), selection), - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, }, RemoteCommand::UpdateSessionTitle { session_id, title } => { if let Err(message) = host.ensure_session_loaded(session_id).await { - return RemoteResponse::Error { message }; + return RemoteResponse::Error { + message, + code: None, + }; } match host.update_session_title(session_id, title).await { @@ -1379,7 +1647,10 @@ where session_id: session_id.clone(), title: normalized_title, }, - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, } } RemoteCommand::GetSessionMessages { @@ -1394,6 +1665,7 @@ where "Session storage directory not available for session: {}", session_id ), + code: None, }; }; let (chat_messages, has_more) = match host @@ -1401,7 +1673,12 @@ where .await { Ok(messages) => messages, - Err(message) => return RemoteResponse::Error { message }, + Err(message) => { + return RemoteResponse::Error { + message, + code: None, + } + } }; remote_messages_response(session_id.clone(), chat_messages, has_more) } @@ -1413,6 +1690,7 @@ where "Session storage directory not available for session: {}", session_id ), + code: None, }; }; @@ -1421,11 +1699,15 @@ where host.remove_tracker(session_id); remote_session_deleted_response(session_id.clone()) } - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, } } _ => RemoteResponse::Error { message: "Unknown session command".into(), + code: None, }, } } @@ -1458,6 +1740,7 @@ where else { return RemoteResponse::Error { message: "expected poll_session".into(), + code: None, }; }; @@ -1492,6 +1775,7 @@ where "Session storage directory not available for session: {}", session_id ), + code: None, }; }; let (all_chat_messages, _) = match host @@ -1499,13 +1783,23 @@ where .await { Ok(messages) => messages, - Err(message) => return RemoteResponse::Error { message }, + Err(message) => { + return RemoteResponse::Error { + message, + code: None, + } + } }; // A history invalidation has no active projection to append against. Send // a replacement snapshot so an equal-count message whose content grew at // completion is repaired on remote controllers. - let message_snapshot = tracker - .is_history_snapshot_required() + // A zero cursor with an already-populated controller cache means the host + // process restarted (or the controller restored a cache before its first + // poll). The new tracker cannot prove that cache's message identities are + // canonical, so return the authoritative persisted snapshot. Older + // clients still receive the additive `new_messages` field below. + let message_snapshot = (tracker.is_history_snapshot_required() + || (*since_version == 0 && *known_msg_count > 0)) .then(|| all_chat_messages.clone()); let total_msg_count = all_chat_messages.len(); let new_messages = all_chat_messages @@ -1544,12 +1838,14 @@ where H: RemoteInteractionRuntimeHost + ?Sized, { match command { - RemoteCommand::ConfirmTool { tool_id } => remote_interaction_accepted_response( + RemoteCommand::ConfirmTool { tool_id, .. } => remote_interaction_accepted_response( "confirm_tool", tool_id.clone(), host.confirm_tool(tool_id).await, ), - RemoteCommand::RejectTool { tool_id, reason } => remote_interaction_accepted_response( + RemoteCommand::RejectTool { + tool_id, reason, .. + } => remote_interaction_accepted_response( "reject_tool", tool_id.clone(), host.reject_tool( @@ -1562,11 +1858,17 @@ where ), RemoteCommand::GetPermissionMode => match host.get_permission_mode().await { Ok(mode) => RemoteResponse::PermissionMode { mode }, - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, }, RemoteCommand::SetPermissionMode { mode } => match host.set_permission_mode(*mode).await { Ok(mode) => RemoteResponse::PermissionMode { mode }, - Err(message) => RemoteResponse::Error { message }, + Err(message) => RemoteResponse::Error { + message, + code: None, + }, }, RemoteCommand::CancelTool { tool_id, reason } => { let cancel_reason = reason @@ -1583,6 +1885,7 @@ where } _ => RemoteResponse::Error { message: "Unknown execution command".into(), + code: None, }, } } @@ -1841,6 +2144,14 @@ pub struct SessionInfo { pub workspace_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub workspace_name: Option, + #[serde(default, skip_serializing_if = "is_unknown_remote_session_kind")] + pub session_kind: RemoteSessionKind, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, +} + +fn is_unknown_remote_session_kind(kind: &RemoteSessionKind) -> bool { + matches!(kind, RemoteSessionKind::Unknown) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -2205,6 +2516,51 @@ pub enum RemoteCommand { images: Option>, image_contexts: Option>, }, + AcpSendMessage { + session_id: String, + content: String, + images: Option>, + image_contexts: Option>, + #[serde(default)] + request_id: Option, + }, + AcpCancelTurn { + session_id: String, + turn_id: Option, + #[serde(default)] + request_id: Option, + }, + AcpGetOptions { + session_id: String, + #[serde(default)] + request_id: Option, + }, + AcpSetOption { + session_id: String, + config_id: String, + value: serde_json::Value, + #[serde(default)] + request_id: Option, + }, + AcpGetCommands { + session_id: String, + #[serde(default)] + request_id: Option, + }, + AcpGetPlan { + session_id: String, + #[serde(default)] + request_id: Option, + }, + /// ACP permission reply. Signature is permission_id + option_id only — + /// native tool ids are rejected by contract (see §8.1 / §13). + AcpPermissionRespond { + session_id: String, + permission_id: String, + option_id: String, + #[serde(default)] + request_id: Option, + }, CancelTask { session_id: String, turn_id: Option, @@ -2218,10 +2574,14 @@ pub enum RemoteCommand { }, ConfirmTool { tool_id: String, + #[serde(default)] + session_id: Option, }, RejectTool { tool_id: String, reason: Option, + #[serde(default)] + session_id: Option, }, GetPermissionMode, SetPermissionMode { @@ -2324,6 +2684,74 @@ pub enum RemoteCommand { }, } +/// ACP metadata projection carried by a remote session poll. +/// +/// Each sub-projection owns an independent, monotonic `version`. These versions +/// are deliberately separate from `SessionPoll::version` (the message snapshot +/// cursor): an ACP metadata refresh must never reuse or overwrite the message +/// snapshot version, so remote controllers can re-render ACP facts by cursor +/// without disturbing their message-stream replay. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteAcpProjectionSnapshot { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_usage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub available_commands: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_options: Option, +} + +impl RemoteAcpProjectionSnapshot { + pub fn is_empty(&self) -> bool { + self.context_usage.is_none() + && self.available_commands.is_none() + && self.plan.is_none() + && self.session_options.is_none() + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteAcpContextUsageProjection { + pub version: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + pub used: u64, + pub size: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteAcpAvailableCommandsProjection { + pub version: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default)] + pub commands: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteAcpPlanProjection { + pub version: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default)] + pub entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteAcpSessionOptionsProjection { + pub version: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, +} + /// Responses sent from desktop back to remote clients. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "resp", rename_all = "snake_case")] @@ -2392,9 +2820,70 @@ pub enum RemoteResponse { session_id: String, turn_id: String, }, + AcpMessageSent { + session_id: String, + capability: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + request_id: Option, + retry: acp_control::RemoteRetryClassification, + turn_id: String, + }, TaskCancelled { session_id: String, }, + AcpTurnCancelled { + session_id: String, + capability: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + request_id: Option, + retry: acp_control::RemoteRetryClassification, + #[serde(default, skip_serializing_if = "Option::is_none")] + turn_id: Option, + }, + AcpOptions { + session_id: String, + capability: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + request_id: Option, + retry: acp_control::RemoteRetryClassification, + options: serde_json::Value, + }, + AcpCommands { + session_id: String, + capability: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + request_id: Option, + retry: acp_control::RemoteRetryClassification, + commands: serde_json::Value, + version: u64, + }, + AcpPlan { + session_id: String, + capability: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + request_id: Option, + retry: acp_control::RemoteRetryClassification, + entries: serde_json::Value, + version: u64, + }, + AcpCommandError { + session_id: String, + capability: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + request_id: Option, + retry: acp_control::RemoteRetryClassification, + code: String, + message: String, + }, + AcpPermissionResolved { + session_id: String, + capability: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + request_id: Option, + retry: acp_control::RemoteRetryClassification, + permission_id: String, + resolved: bool, + }, SessionDeleted { session_id: String, }, @@ -2436,6 +2925,8 @@ pub enum RemoteResponse { active_turn: Option, #[serde(skip_serializing_if = "Option::is_none")] model_catalog: Box>, + #[serde(default, skip_serializing_if = "Option::is_none")] + acp_projection: Option, }, AnswerAccepted, InteractionAccepted { @@ -2515,6 +3006,8 @@ pub enum RemoteResponse { }, Error { message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + code: Option, }, } @@ -2538,6 +3031,7 @@ pub trait RemoteCommandRuntimeHost: Send + Sync { async fn handle_device_command(&self, _command: &RemoteCommand) -> RemoteResponse { RemoteResponse::Error { message: "Device-to-device commands are not supported on this device".to_string(), + code: None, } } @@ -2548,6 +3042,80 @@ pub trait RemoteCommandRuntimeHost: Send + Sync { async fn cancel_task(&self, request: RemoteCancelTaskRequest) -> Result<(), String>; + /// Execute an ACP remote-control command. Default keeps the P0 + /// unsupported contract until a product host injects a real adapter. + async fn handle_acp_control_command(&self, command: &RemoteCommand) -> RemoteResponse { + match command { + RemoteCommand::AcpPermissionRespond { + session_id, + request_id, + .. + } => acp_control::acp_permission_respond_unsupported(session_id, request_id.clone()), + RemoteCommand::AcpSendMessage { + session_id, + request_id, + .. + } + | RemoteCommand::AcpCancelTurn { + session_id, + request_id, + .. + } + | RemoteCommand::AcpGetOptions { + session_id, + request_id, + .. + } + | RemoteCommand::AcpSetOption { + session_id, + request_id, + .. + } + | RemoteCommand::AcpGetCommands { + session_id, + request_id, + .. + } + | RemoteCommand::AcpGetPlan { + session_id, + request_id, + .. + } => acp_control::RemoteAcpControlError::unsupported( + session_id.clone(), + request_id.clone(), + ) + .into_response(), + _ => RemoteResponse::Error { + message: "Not an ACP control command".to_string(), + code: None, + }, + } + } + + /// Whether native Confirm/Reject must be blocked for this ACP target. + async fn reject_native_tool_interaction_for_acp( + &self, + session_id: Option<&str>, + tool_id: &str, + ) -> Option { + let _ = (session_id, tool_id); + None + } + + /// Whether a native *session-scoped control* command must be blocked + /// because the target is an ACP session. `cancel_task` and + /// `set_session_model` reach the native scheduler and the native session + /// metadata respectively; for an externally projected ACP session both are + /// the agent's business, not ours. Default keeps old hosts unchanged. + async fn reject_native_session_control_for_acp( + &self, + session_id: &str, + command_name: &str, + ) -> Option { + let _ = (session_id, command_name); + None + } + fn legacy_image_contexts(&self, images: Option<&[ImageAttachment]>) -> Vec; fn explicit_image_contexts(&self, contexts: Vec) @@ -2574,11 +3142,24 @@ where RemoteCommand::ListSessions { .. } | RemoteCommand::CreateSession { .. } | RemoteCommand::GetModelCatalog { .. } - | RemoteCommand::SetSessionModel { .. } | RemoteCommand::UpdateSessionTitle { .. } | RemoteCommand::GetSessionMessages { .. } | RemoteCommand::DeleteSession { .. } => host.handle_session_command(command).await, + // Native model selection is not a thing an ACP session has: the agent + // owns its model/config surface behind `acp_get_options`. Reading the + // catalog stays allowed (`GetModelCatalog` above) so the UI can still + // render "observable, not selectable". + RemoteCommand::SetSessionModel { session_id, .. } => { + if let Some(rejected) = host + .reject_native_session_control_for_acp(session_id, "set_session_model") + .await + { + return rejected; + } + host.handle_session_command(command).await + } + RemoteCommand::PollSession { .. } => host.handle_poll_command(command).await, RemoteCommand::ReadFile { .. } @@ -2590,7 +3171,35 @@ where | RemoteCommand::GetPermissionMode | RemoteCommand::SetPermissionMode { .. } | RemoteCommand::CancelTool { .. } - | RemoteCommand::AnswerQuestion { .. } => host.handle_interaction_command(command).await, + | RemoteCommand::AnswerQuestion { .. } => { + let (session_id, tool_id) = match command { + RemoteCommand::ConfirmTool { + tool_id, + session_id, + } => (session_id.as_deref(), tool_id.as_str()), + RemoteCommand::RejectTool { + tool_id, + session_id, + .. + } => (session_id.as_deref(), tool_id.as_str()), + // These two carry no session_id on the wire, so only the + // permission-id half of the guard can fire — but it must fire. + // Leaving them in the `_` bucket let a native cancel/answer + // address an ACP permission id unchecked. + RemoteCommand::CancelTool { tool_id, .. } => (None, tool_id.as_str()), + RemoteCommand::AnswerQuestion { tool_id, .. } => (None, tool_id.as_str()), + _ => (None, ""), + }; + if !tool_id.is_empty() { + if let Some(rejected) = host + .reject_native_tool_interaction_for_acp(session_id, tool_id) + .await + { + return rejected; + } + } + host.handle_interaction_command(command).await + } RemoteCommand::SendMessage { session_id, @@ -2624,29 +3233,51 @@ where ) } + RemoteCommand::AcpSendMessage { .. } + | RemoteCommand::AcpCancelTurn { .. } + | RemoteCommand::AcpGetOptions { .. } + | RemoteCommand::AcpSetOption { .. } + | RemoteCommand::AcpGetCommands { .. } + | RemoteCommand::AcpGetPlan { .. } + | RemoteCommand::AcpPermissionRespond { .. } => { + host.handle_acp_control_command(command).await + } + RemoteCommand::CancelTask { session_id, turn_id, - } => remote_task_cancel_response( - session_id.clone(), - host.cancel_task(RemoteCancelTaskRequest { - session_id: session_id.clone(), - requested_turn_id: turn_id.clone(), - }) - .await, - ), + } => { + // The native cancel path talks to the native scheduler. An ACP turn + // lives in the agent process and is cancelled with `acp_cancel_turn`. + if let Some(rejected) = host + .reject_native_session_control_for_acp(session_id, "cancel_task") + .await + { + return rejected; + } + remote_task_cancel_response( + session_id.clone(), + host.cancel_task(RemoteCancelTaskRequest { + session_id: session_id.clone(), + requested_turn_id: turn_id.clone(), + }) + .await, + ) + } // Answered by the host runtime (which owns the delegated identity // provider) before dispatch reaches this router; this is the fallback // for hosts that cannot delegate an account identity. RemoteCommand::GetDelegatedIdentity => RemoteResponse::Error { message: "Delegated identity is not available on this host".to_string(), + code: None, }, // Same contract as GetDelegatedIdentity above: the host runtime owns the // account credentials and answers before dispatch reaches this router. RemoteCommand::ProvisionPeerDevice { .. } => RemoteResponse::Error { message: "Device provisioning is not available on this host".to_string(), + code: None, }, RemoteCommand::SendSessionToDevice { .. } @@ -2697,6 +3328,10 @@ struct TrackerState { persistence_dirty: bool, history_snapshot_required: bool, linked_subagent_sessions: HashMap, + acp_context_usage: Option, + acp_available_commands: Option, + acp_plan: Option, + acp_session_options: Option, } /// Lightweight event broadcast by the tracker for real-time consumers. @@ -2755,6 +3390,10 @@ impl RemoteSessionStateTracker { persistence_dirty: true, history_snapshot_required: false, linked_subagent_sessions: HashMap::new(), + acp_context_usage: None, + acp_available_commands: None, + acp_plan: None, + acp_session_options: None, }), event_tx, } @@ -2772,6 +3411,93 @@ impl RemoteSessionStateTracker { self.version.fetch_add(1, Ordering::Relaxed); } + fn next_acp_version(&self, current: Option) -> u64 { + current.map_or(1, |version| version + 1) + } + + pub fn record_acp_context_usage( + &self, + turn_id: String, + client_id: String, + used: u64, + size: u64, + cost: Option, + ) { + let mut state = self.state.write().unwrap(); + let version = self.next_acp_version(state.acp_context_usage.as_ref().map(|p| p.version)); + state.acp_context_usage = Some(RemoteAcpContextUsageProjection { + version, + turn_id: Some(turn_id), + client_id: Some(client_id), + used, + size, + cost, + }); + drop(state); + self.bump_version(); + } + + pub fn record_acp_available_commands( + &self, + client_id: String, + commands: Vec, + ) { + let mut state = self.state.write().unwrap(); + let version = + self.next_acp_version(state.acp_available_commands.as_ref().map(|p| p.version)); + state.acp_available_commands = Some(RemoteAcpAvailableCommandsProjection { + version, + client_id: Some(client_id), + commands, + }); + drop(state); + self.bump_version(); + } + + pub fn record_acp_plan( + &self, + turn_id: String, + client_id: String, + entries: Vec, + ) { + let mut state = self.state.write().unwrap(); + let version = self.next_acp_version(state.acp_plan.as_ref().map(|p| p.version)); + state.acp_plan = Some(RemoteAcpPlanProjection { + version, + turn_id: Some(turn_id), + client_id: Some(client_id), + entries, + }); + drop(state); + self.bump_version(); + } + + pub fn record_acp_session_options_changed(&self, client_id: String) { + let mut state = self.state.write().unwrap(); + let version = self.next_acp_version(state.acp_session_options.as_ref().map(|p| p.version)); + state.acp_session_options = Some(RemoteAcpSessionOptionsProjection { + version, + client_id: Some(client_id), + }); + drop(state); + self.bump_version(); + } + + pub fn snapshot_acp_projection(&self) -> Option { + let state = self.state.read().unwrap(); + let snapshot = RemoteAcpProjectionSnapshot { + context_usage: state.acp_context_usage.clone(), + available_commands: state.acp_available_commands.clone(), + plan: state.acp_plan.clone(), + session_options: state.acp_session_options.clone(), + }; + if snapshot.is_empty() { + None + } else { + Some(snapshot) + } + } + pub fn snapshot_active_turn(&self) -> Option { let state = self.state.read().unwrap(); let has_items = !state.active_items.is_empty(); @@ -2909,6 +3635,16 @@ impl RemoteSessionStateTracker { self.state.read().unwrap().history_snapshot_required } + /// Fail-loud when the durable transcript writer could not commit. + /// Keeps the live projection; remote poll must re-read persisted history. + pub fn require_history_snapshot(&self) { + let mut state = self.state.write().unwrap(); + state.persistence_dirty = true; + state.history_snapshot_required = true; + drop(state); + self.bump_version(); + } + fn invalidate_history_projection(&self) { let mut state = self.state.write().unwrap(); state.turn_id = None; @@ -3380,6 +4116,38 @@ impl RemoteSessionStateTracker { drop(state); self.bump_version(); } + AE::AcpContextUsageUpdated { + turn_id, + client_id, + used, + size, + cost, + .. + } if is_direct => self.record_acp_context_usage( + turn_id.clone(), + client_id.clone(), + *used, + *size, + cost.clone(), + ), + AE::AcpAvailableCommandsUpdated { + client_id, + commands, + .. + } if is_direct => { + self.record_acp_available_commands(client_id.clone(), commands.clone()) + } + AE::AcpPlanUpdated { + turn_id, + client_id, + entries, + .. + } if is_direct => { + self.record_acp_plan(turn_id.clone(), client_id.clone(), entries.clone()) + } + AE::AcpSessionOptionsChanged { client_id, .. } if is_direct => { + self.record_acp_session_options_changed(client_id.clone()) + } AE::SessionHistoryChanged { settled_turn_id, .. } if is_direct => match settled_turn_id { @@ -3496,6 +4264,7 @@ pub fn remote_no_change_poll_response(version: u64) -> RemoteResponse { message_snapshot: None, active_turn: None, model_catalog: Box::new(None), + acp_projection: None, } } @@ -3517,6 +4286,7 @@ pub fn remote_snapshot_poll_response( message_snapshot: None, active_turn, model_catalog: Box::new(model_catalog), + acp_projection: tracker.snapshot_acp_projection(), } } @@ -3575,6 +4345,7 @@ pub fn remote_persisted_poll_response( message_snapshot: send_snapshot, active_turn, model_catalog: Box::new(model_catalog), + acp_projection: tracker.snapshot_acp_projection(), } } @@ -3721,6 +4492,8 @@ mod tests { created_at_ms: 1_000, last_active_at_ms: 2_000, turn_count: 3, + session_kind: RemoteSessionKind::Native, + capabilities: Vec::new(), }, RemoteSessionMetadata { session_id: "session-b".to_string(), @@ -3729,6 +4502,8 @@ mod tests { created_at_ms: 1_000, last_active_at_ms: 2_000, turn_count: 1, + session_kind: RemoteSessionKind::Native, + capabilities: Vec::new(), }, ]) } @@ -3983,6 +4758,7 @@ mod tests { response, RemoteResponse::Error { message: "Session history restore is incomplete".to_string(), + code: None, } ); } @@ -4043,6 +4819,7 @@ mod tests { RemoteResponse::Error { message: "Session storage directory not available for session: session-a" .to_string(), + code: None, } ); } @@ -4090,10 +4867,70 @@ mod tests { message_snapshot: None, active_turn: None, model_catalog: Box::new(None), + acp_projection: None, } ); } + #[tokio::test] + async fn restarted_host_replaces_an_existing_controller_cache() { + let messages = vec![ + ChatMessage { + id: "turn-1-user".to_string(), + role: "user".to_string(), + content: "hello".to_string(), + timestamp: "1".to_string(), + metadata: None, + tools: None, + thinking: None, + items: None, + images: None, + }, + ChatMessage { + id: "turn-1-assistant".to_string(), + role: "assistant".to_string(), + content: "world".to_string(), + timestamp: "2".to_string(), + metadata: None, + tools: None, + thinking: None, + items: None, + images: None, + }, + ]; + let host = FakePollHost { + tracker: Arc::new(RemoteSessionStateTracker::new("session-a".to_string())), + storage_dir: Some(PathBuf::from("/workspace/project/.bitfun/sessions")), + messages: messages.clone(), + history_read_count: Arc::new(AtomicUsize::new(0)), + }; + + let response = handle_remote_poll_command( + &host, + &RemoteCommand::PollSession { + session_id: "session-a".to_string(), + since_version: 0, + known_msg_count: 2, + known_model_catalog_version: None, + }, + ) + .await; + + match response { + RemoteResponse::SessionPoll { + new_messages, + total_msg_count, + message_snapshot, + .. + } => { + assert_eq!(new_messages, Some(Vec::new())); + assert_eq!(total_msg_count, Some(2)); + assert_eq!(message_snapshot, Some(messages)); + } + other => panic!("expected SessionPoll, got {other:?}"), + } + } + #[tokio::test] async fn history_change_invalidates_clean_poll_cache_and_reports_a_shorter_projection() { let tracker = Arc::new(RemoteSessionStateTracker::new("session-a".to_string())); @@ -4170,6 +5007,7 @@ mod tests { }]), active_turn: None, model_catalog: Box::new(None), + acp_projection: None, } ); assert!(!tracker.is_persistence_dirty()); diff --git a/src/crates/services/services-integrations/src/remote_connect/acp_control.rs b/src/crates/services/services-integrations/src/remote_connect/acp_control.rs new file mode 100644 index 0000000000..74d281492c --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/acp_control.rs @@ -0,0 +1,358 @@ +//! ACP remote-control command host and response helpers. +//! +//! Wire commands live on [`super::RemoteCommand`]. This module owns the +//! execution port and the ACP-shaped responses that carry session identity, +//! capability, request id, and retry classification. It must not depend on +//! `bitfun-acp`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{ + remote_unsupported_response, ImageAttachment, RemoteImageContext, RemoteResponse, + ACP_SESSION_REQUIRES_ACP_CONTROL_MESSAGE, REMOTE_CAPABILITY_ACP_REMOTE_CONTROL, + UNSUPPORTED_REMOTE_CAPABILITY, +}; + +/// Whether a remote ACP command failure is safe to retry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoteRetryClassification { + Retryable, + Terminal, + Stale, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteAcpSendRequest { + pub session_id: String, + pub content: String, + pub images: Option>, + pub image_contexts: Option>, + pub request_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAcpCancelRequest { + pub session_id: String, + pub turn_id: Option, + pub request_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAcpGetOptionsRequest { + pub session_id: String, + pub request_id: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteAcpSetOptionRequest { + pub session_id: String, + pub config_id: String, + pub value: Value, + pub request_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAcpGetCommandsRequest { + pub session_id: String, + pub request_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAcpGetPlanRequest { + pub session_id: String, + pub request_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAcpPermissionRespondRequest { + pub session_id: String, + pub permission_id: String, + pub option_id: String, + pub request_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAcpPermissionRespondOutcome { + pub session_id: String, + pub permission_id: String, + pub request_id: Option, + pub resolved: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAcpSendOutcome { + pub session_id: String, + pub turn_id: String, + pub request_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAcpCancelOutcome { + pub session_id: String, + pub turn_id: Option, + pub request_id: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteAcpOptionsOutcome { + pub session_id: String, + pub request_id: Option, + pub options: Value, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteAcpCommandsOutcome { + pub session_id: String, + pub request_id: Option, + pub commands: Value, + pub version: u64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteAcpPlanOutcome { + pub session_id: String, + pub request_id: Option, + pub entries: Value, + pub version: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAcpControlError { + pub session_id: String, + pub request_id: Option, + pub code: String, + pub message: String, + pub retry: RemoteRetryClassification, +} + +impl RemoteAcpControlError { + pub fn unsupported(session_id: impl Into, request_id: Option) -> Self { + let session_id = session_id.into(); + Self { + session_id: session_id.clone(), + request_id, + code: UNSUPPORTED_REMOTE_CAPABILITY.to_string(), + message: format!("{ACP_SESSION_REQUIRES_ACP_CONTROL_MESSAGE} (session={session_id})"), + retry: RemoteRetryClassification::Terminal, + } + } + + pub fn terminal( + session_id: impl Into, + request_id: Option, + code: impl Into, + message: impl Into, + ) -> Self { + Self { + session_id: session_id.into(), + request_id, + code: code.into(), + message: message.into(), + retry: RemoteRetryClassification::Terminal, + } + } + + pub fn into_response(self) -> RemoteResponse { + RemoteResponse::AcpCommandError { + session_id: self.session_id, + capability: REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string(), + request_id: self.request_id, + retry: self.retry, + code: self.code, + message: self.message, + } + } +} + +/// Product host that executes ACP remote-control commands without exposing +/// ACP process handles to the phone. +#[async_trait::async_trait] +pub trait RemoteAcpControlRuntimeHost: Send + Sync { + async fn send_message( + &self, + request: RemoteAcpSendRequest, + ) -> Result; + + async fn cancel_turn( + &self, + request: RemoteAcpCancelRequest, + ) -> Result; + + async fn get_options( + &self, + request: RemoteAcpGetOptionsRequest, + ) -> Result; + + async fn set_option( + &self, + request: RemoteAcpSetOptionRequest, + ) -> Result; + + async fn get_commands( + &self, + request: RemoteAcpGetCommandsRequest, + ) -> Result; + + async fn get_plan( + &self, + request: RemoteAcpGetPlanRequest, + ) -> Result; + + async fn permission_respond( + &self, + request: RemoteAcpPermissionRespondRequest, + ) -> Result; + + /// True when `session_id` is an ACP session that must not accept native + /// ConfirmTool / RejectTool (even when only a tool id is present). + async fn is_acp_session(&self, session_id: &str) -> bool; + + /// True when `tool_id` is an ACP permission id that native Confirm/Reject + /// must not convert. + async fn is_acp_permission_id(&self, tool_id: &str) -> bool; + + /// Drop idempotent request caches for a finished ACP session. + fn clear_session_idempotency(&self, _session_id: &str) {} +} + +pub fn acp_permission_respond_response( + result: Result, +) -> RemoteResponse { + match result { + Ok(outcome) => RemoteResponse::AcpPermissionResolved { + session_id: outcome.session_id, + capability: REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string(), + request_id: outcome.request_id, + retry: RemoteRetryClassification::Terminal, + permission_id: outcome.permission_id, + resolved: outcome.resolved, + }, + Err(error) => error.into_response(), + } +} + +pub fn acp_permission_respond_unsupported( + session_id: &str, + request_id: Option, +) -> RemoteResponse { + RemoteAcpControlError::terminal( + session_id, + request_id, + UNSUPPORTED_REMOTE_CAPABILITY, + format!("ACP permission respond requires the Desktop-owned mailbox (session={session_id})"), + ) + .into_response() +} + +pub fn acp_native_tool_interaction_unsupported( + session_id: Option<&str>, + tool_id: &str, +) -> RemoteResponse { + let message = match session_id { + Some(session_id) => format!( + "Native tool confirmation is unsupported for ACP sessions (session={session_id}, tool_id={tool_id})" + ), + None => format!( + "Native tool confirmation cannot target ACP permission ids (tool_id={tool_id})" + ), + }; + remote_unsupported_response(UNSUPPORTED_REMOTE_CAPABILITY, message) +} + +/// Fail loud when a *native* session-scoped control command targets an ACP +/// session. Native cancel and native model selection have no meaning for a +/// session whose turns the Runtime does not own: the ACP agent owns its own +/// turn lifecycle (`acp_cancel_turn`) and its own model/config surface +/// (`acp_get_options` / `acp_set_option`). Silently forwarding these to the +/// native scheduler would admit an ACP session into `SessionManager`, which is +/// exactly the failure §13 tells us to watch for. +pub fn acp_native_session_control_unsupported( + session_id: &str, + command_name: &str, +) -> RemoteResponse { + remote_unsupported_response( + UNSUPPORTED_REMOTE_CAPABILITY, + format!( + "Native `{command_name}` is unsupported for ACP sessions; use the acp_* command family (session={session_id})" + ), + ) +} + +pub fn acp_send_response( + result: Result, +) -> RemoteResponse { + match result { + Ok(outcome) => RemoteResponse::AcpMessageSent { + session_id: outcome.session_id, + capability: REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string(), + request_id: outcome.request_id, + retry: RemoteRetryClassification::Terminal, + turn_id: outcome.turn_id, + }, + Err(error) => error.into_response(), + } +} + +pub fn acp_cancel_response( + result: Result, +) -> RemoteResponse { + match result { + Ok(outcome) => RemoteResponse::AcpTurnCancelled { + session_id: outcome.session_id, + capability: REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string(), + request_id: outcome.request_id, + retry: RemoteRetryClassification::Terminal, + turn_id: outcome.turn_id, + }, + Err(error) => error.into_response(), + } +} + +pub fn acp_options_response( + result: Result, +) -> RemoteResponse { + match result { + Ok(outcome) => RemoteResponse::AcpOptions { + session_id: outcome.session_id, + capability: REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string(), + request_id: outcome.request_id, + retry: RemoteRetryClassification::Terminal, + options: outcome.options, + }, + Err(error) => error.into_response(), + } +} + +pub fn acp_commands_response( + result: Result, +) -> RemoteResponse { + match result { + Ok(outcome) => RemoteResponse::AcpCommands { + session_id: outcome.session_id, + capability: REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string(), + request_id: outcome.request_id, + retry: RemoteRetryClassification::Terminal, + commands: outcome.commands, + version: outcome.version, + }, + Err(error) => error.into_response(), + } +} + +pub fn acp_plan_response( + result: Result, +) -> RemoteResponse { + match result { + Ok(outcome) => RemoteResponse::AcpPlan { + session_id: outcome.session_id, + capability: REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string(), + request_id: outcome.request_id, + retry: RemoteRetryClassification::Terminal, + entries: outcome.entries, + version: outcome.version, + }, + Err(error) => error.into_response(), + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/acp_permission_mailbox.rs b/src/crates/services/services-integrations/src/remote_connect/acp_permission_mailbox.rs new file mode 100644 index 0000000000..6305d0dabf --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/acp_permission_mailbox.rs @@ -0,0 +1,160 @@ +//! Shared ACP permission mailbox view for Desktop UI and Remote Poll. +//! +//! Product hosts (Desktop) write entries when ACP asks for permission. Remote +//! poll sync reads the same map. Entries survive disconnect; ACP timeout still +//! clears them when the oneshot converges to Cancelled. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AcpPermissionMailboxEntry { + pub permission_id: String, + pub session_id: String, + pub tool_call: Value, + pub options: Value, + pub created_at_ms: u64, + pub expires_at_ms: u64, +} + +#[derive(Default)] +pub struct AcpPermissionMailbox { + pending: Mutex>, +} + +impl AcpPermissionMailbox { + pub fn insert(&self, entry: AcpPermissionMailboxEntry) { + self.pending + .lock() + .expect("ACP permission mailbox") + .insert(entry.permission_id.clone(), entry); + } + + pub fn remove(&self, permission_id: &str) -> Option { + self.pending + .lock() + .expect("ACP permission mailbox") + .remove(permission_id) + } + + pub fn get(&self, permission_id: &str) -> Option { + self.pending + .lock() + .expect("ACP permission mailbox") + .get(permission_id) + .cloned() + } + + pub fn list_for_session(&self, session_id: &str) -> Vec { + self.pending + .lock() + .expect("ACP permission mailbox") + .values() + .filter(|entry| entry.session_id == session_id) + .cloned() + .collect() + } + + pub fn clear_session(&self, session_id: &str) { + self.pending + .lock() + .expect("ACP permission mailbox") + .retain(|_, entry| entry.session_id != session_id); + } + + pub fn contains(&self, permission_id: &str) -> bool { + self.pending + .lock() + .expect("ACP permission mailbox") + .contains_key(permission_id) + } +} + +static MAILBOX: OnceLock> = OnceLock::new(); + +pub fn install_acp_permission_mailbox( + mailbox: Arc, +) -> Arc { + let _ = MAILBOX.set(mailbox.clone()); + mailbox +} + +pub fn acp_permission_mailbox() -> Option> { + MAILBOX.get().cloned() +} + +pub fn acp_permission_now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +pub fn sync_acp_permission_mailbox_into_tracker( + session_id: &str, + tracker: &super::RemoteSessionStateTracker, +) { + let Some(mailbox) = acp_permission_mailbox() else { + return; + }; + for entry in mailbox.list_for_session(session_id) { + let tool_id = entry + .tool_call + .get("toolCallId") + .or_else(|| entry.tool_call.get("tool_call_id")) + .and_then(|value| value.as_str()) + .unwrap_or(entry.permission_id.as_str()) + .to_string(); + let tool_name = entry + .tool_call + .get("title") + .or_else(|| entry.tool_call.get("kind")) + .and_then(|value| value.as_str()) + .unwrap_or("acp_permission") + .to_string(); + let tool_input = Some(serde_json::json!({ + "permissionId": entry.permission_id, + "options": entry.options, + "toolCall": entry.tool_call, + "expiresAtMs": entry.expires_at_ms, + })); + let input_preview = tool_input + .as_ref() + .and_then(|input| serde_json::to_string(input).ok()); + tracker.sync_pending_permission(tool_id, tool_name, input_preview, tool_input); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clear_session_removes_only_matching_entries() { + let mailbox = AcpPermissionMailbox::default(); + mailbox.insert(AcpPermissionMailboxEntry { + permission_id: "p1".to_string(), + session_id: "s1".to_string(), + tool_call: serde_json::json!({}), + options: serde_json::json!([]), + created_at_ms: 1, + expires_at_ms: 2, + }); + mailbox.insert(AcpPermissionMailboxEntry { + permission_id: "p2".to_string(), + session_id: "s2".to_string(), + tool_call: serde_json::json!({}), + options: serde_json::json!([]), + created_at_ms: 1, + expires_at_ms: 2, + }); + mailbox.clear_session("s1"); + assert!(mailbox.list_for_session("s1").is_empty()); + assert_eq!(mailbox.list_for_session("s2").len(), 1); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/acp_bot_policy.rs b/src/crates/services/services-integrations/src/remote_connect/bot/acp_bot_policy.rs new file mode 100644 index 0000000000..873091e6ea --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/bot/acp_bot_policy.rs @@ -0,0 +1,105 @@ +//! Bot-side ACP session policy helpers. +//! +//! IM bots must not resume or drive externally projected ACP sessions through +//! the native SessionManager / `send_message` path. These helpers are pure so +//! product assembly and contract tests can share one decision. + +use bitfun_core_types::SESSION_PROVIDER_ACP; +use bitfun_runtime_ports::RemoteSessionKind; +use serde_json::Value; + +/// True when persisted custom metadata marks the session as ACP-owned. +pub fn is_acp_session_provider(provider: Option<&str>) -> bool { + provider == Some(SESSION_PROVIDER_ACP) +} + +/// True when a remote `SessionInfo` JSON row is an ACP session. +/// +/// Prefer `session_kind`; never treat missing/unknown kind as native — use the +/// `acp_remote_control` capability as a secondary signal for older payloads. +pub fn remote_session_json_is_acp(session: &Value) -> bool { + match session.get("session_kind").and_then(Value::as_str) { + Some(kind) if kind == RemoteSessionKind::Acp.as_wire_str() => true, + Some(kind) if kind == RemoteSessionKind::Native.as_wire_str() => false, + _ => session + .get("capabilities") + .and_then(Value::as_array) + .is_some_and(|caps| { + caps.iter().any(|cap| { + cap.as_str() == Some(bitfun_runtime_ports::REMOTE_CAPABILITY_ACP_REMOTE_CONTROL) + }) + }), + } +} + +/// When a remote RPC body is `RemoteResponse::Error`, return its message. +pub fn remote_rpc_error_message(resp_json: &str) -> Option { + let value: Value = serde_json::from_str(resp_json).ok()?; + if value.get("resp").and_then(Value::as_str) != Some("error") { + return None; + } + Some( + value + .get("message") + .and_then(Value::as_str) + .unwrap_or("Remote command failed") + .to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn provider_marks_only_acp() { + assert!(is_acp_session_provider(Some("acp"))); + assert!(!is_acp_session_provider(Some("native"))); + assert!(!is_acp_session_provider(None)); + } + + #[test] + fn remote_json_uses_session_kind_and_capability_fallback() { + assert!(remote_session_json_is_acp(&json!({ + "session_id": "a", + "session_kind": "acp" + }))); + assert!(!remote_session_json_is_acp(&json!({ + "session_id": "a", + "session_kind": "native" + }))); + assert!(!remote_session_json_is_acp(&json!({ + "session_id": "a", + "session_kind": "unknown" + }))); + assert!(remote_session_json_is_acp(&json!({ + "session_id": "a", + "session_kind": "unknown", + "capabilities": ["acp_remote_control"] + }))); + assert!(remote_session_json_is_acp(&json!({ + "session_id": "a", + "capabilities": ["acp_remote_control"] + }))); + assert!(!remote_session_json_is_acp(&json!({ + "session_id": "a", + "capabilities": ["other"] + }))); + } + + #[test] + fn remote_rpc_error_is_detected_and_message_extracted() { + assert_eq!( + remote_rpc_error_message( + r#"{"resp":"error","message":"ACP sessions require acp_remote_control"}"# + ) + .as_deref(), + Some("ACP sessions require acp_remote_control") + ); + assert_eq!( + remote_rpc_error_message(r#"{"resp":"message_sent","turn_id":"t1"}"#), + None + ); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs b/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs index 4f452af384..d57e115fa1 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs @@ -186,6 +186,10 @@ pub struct BotStrings { pub devices_switched_local: &'static str, pub devices_remote_prefix: &'static str, pub devices_msg_sent: &'static str, + /// ACP sessions are observation-only on IM until P3 remote-control UX lands. + pub acp_session_unsupported: &'static str, + /// Remote resume hit consecutive ACP-only pages; ask the user to page onward. + pub resume_acp_only_pages_hint: &'static str, } const STRINGS_ZH: BotStrings = BotStrings { @@ -343,6 +347,8 @@ const STRINGS_ZH: BotStrings = BotStrings { devices_switched_local: "已切换回本地设备", devices_remote_prefix: "远程设备", devices_msg_sent: "消息已发送,远程 agent 正在执行", + acp_session_unsupported: "该会话由外部 ACP 代理驱动,当前 IM 机器人不能发送或恢复。请在 Desktop / 手机端使用 ACP 远程控制。", + resume_acp_only_pages_hint: "本页没有可在机器人中恢复的会话(已跳过若干 ACP 会话)。回复 0 继续翻页,或发送 /menu 返回。", }; const STRINGS_ZH_TW: BotStrings = BotStrings { @@ -500,6 +506,8 @@ const STRINGS_ZH_TW: BotStrings = BotStrings { devices_switched_local: "已切換回本地裝置", devices_remote_prefix: "遠端裝置", devices_msg_sent: "訊息已傳送,遠端 agent 正在執行", + acp_session_unsupported: "此工作階段由外部 ACP 代理驅動,目前 IM 機器人無法傳送或恢復。請在 Desktop / 手機端使用 ACP 遠端控制。", + resume_acp_only_pages_hint: "本頁沒有可在機器人中恢復的會話(已跳過若干 ACP 會話)。回覆 0 繼續翻頁,或發送 /menu 返回。", }; const STRINGS_EN: BotStrings = BotStrings { @@ -659,6 +667,8 @@ Open Remote Connect in BitFun Desktop and send the 6-digit pairing code here to devices_switched_local: "Switched back to local device", devices_remote_prefix: "Remote device", devices_msg_sent: "Message sent, remote agent is working", + acp_session_unsupported: "This session is driven by an external ACP agent. The IM bot cannot send or resume it yet. Use Desktop or mobile ACP remote control.", + resume_acp_only_pages_hint: "No bot-resumable sessions on this page (ACP-only pages were skipped). Reply 0 for the next page, or send /menu to go back.", }; pub fn strings_for(language: BotLanguage) -> &'static BotStrings { diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs index bd90d22573..2296b5b28f 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs @@ -3,6 +3,7 @@ //! Platform adapters and command routing remain in product assembly until their //! concrete session/runtime dependencies are reduced to service ports. +mod acp_bot_policy; mod command; pub mod feishu; mod locale; @@ -14,6 +15,9 @@ pub mod weixin; use serde::{Deserialize, Serialize}; use std::sync::{Mutex as StdMutex, OnceLock}; +pub use acp_bot_policy::{ + is_acp_session_provider, remote_rpc_error_message, remote_session_json_is_acp, +}; pub use command::{parse_command, BotCommand}; pub use feishu::{FeishuBotApi, FeishuConfig}; pub use locale::{fmt_count, strings_for, BotLanguage, BotStrings}; diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 541b904291..6b0a5a425e 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -5,7 +5,7 @@ use bitfun_core_types::{ ModelsDevReasoningProvider, ReasoningCapabilityStatus, ReasoningCatalogProjection, ReasoningPresetAction, ReasoningPresetDescriptor, ReasoningPresetSource, }; -use bitfun_events::{AgenticEvent, ToolEventData}; +use bitfun_events::{AcpAvailableCommandFact, AcpPlanEntryFact, AgenticEvent, ToolEventData}; use bitfun_runtime_ports::{ AgentSubmissionSource, RemoteControlSessionState, RemoteControlStateSnapshot, }; @@ -15,8 +15,9 @@ use bitfun_services_integrations::remote_connect::{ build_remote_image_submission_request, build_remote_model_catalog, build_remote_session_create_request, build_remote_submission_request, cancel_remote_task, handle_remote_command, handle_remote_workspace_file_command, make_slim_tool_params, - normalize_remote_model_selection, normalize_remote_session_model_id, project_remote_chat_user, - read_remote_workspace_file, read_remote_workspace_file_chunk, read_remote_workspace_file_info, + normalize_remote_model_selection, normalize_remote_session_model_id, parse_remote_command, + project_remote_chat_user, read_remote_workspace_file, read_remote_workspace_file_chunk, + read_remote_workspace_file_info, reject_native_dialog_for_session, remote_answer_question_response, remote_assistant_list_response, remote_assistant_updated_response, remote_dialog_submit_outcome_from_scheduler, remote_dialog_submit_response, remote_file_chunk_response, remote_file_content_response, @@ -31,24 +32,29 @@ use bitfun_services_integrations::remote_connect::{ remote_workspace_updated_response, resolve_remote_agent_type, resolve_remote_cancel_decision, resolve_remote_execution_image_contexts, resolve_remote_file_chunk_range, resolve_remote_workspace_path, should_send_remote_model_catalog, submit_remote_dialog, - ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, ChatMessageItem, DeviceIdentity, - ImageAttachment, KeyPair, PairingChallenge, PairingProtocol, PairingResponse, PairingState, - QrGenerator, QrPayload, RelayMessage, RemoteAssistantWorkspaceFacts, RemoteCancelDecision, - RemoteCancelRuntimeHost, RemoteCancelTaskRequest, RemoteChatHistoryRound, - RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, - RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, - RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, - RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, - RemoteDialogSubmissionPolicy, RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, - RemoteDialogWorkspaceBinding, RemoteImageContext, RemoteImageContextAdapter, - RemoteModelCapabilityFact, RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelConfig, - RemoteModelFacts, RemoteRecentWorkspaceFacts, RemoteResponse, RemoteSessionMetadata, - RemoteSessionModelSelection, RemoteSessionStateTracker, RemoteSessionTrackerHost, - RemoteSessionTrackerRegistry, RemoteSessionWorkspaceIdentity, RemoteTerminalPrewarmRequest, - RemoteToolStatus, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, RemoteWorkspaceFileContent, - RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, - RemoteWorkspaceUpdate, TrackerEvent, REMOTE_FILE_MAX_CHUNK_BYTES, REMOTE_FILE_MAX_READ_BYTES, + AcpPermissionMailbox, AcpPermissionMailboxEntry, ActiveTurnSnapshot, ChatImageAttachment, + ChatMessage, ChatMessageItem, DeviceIdentity, ImageAttachment, KeyPair, PairingChallenge, + PairingProtocol, PairingResponse, PairingState, QrGenerator, QrPayload, RelayMessage, + RemoteAssistantWorkspaceFacts, RemoteCancelDecision, RemoteCancelRuntimeHost, + RemoteCancelTaskRequest, RemoteChatHistoryRound, RemoteChatHistoryTextItem, + RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, + RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, RemoteConnectSubmissionSource, + RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, + RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSubmissionPolicy, + RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, + RemoteImageContext, RemoteImageContextAdapter, RemoteModelCapabilityFact, RemoteModelCatalog, + RemoteModelCatalogFacts, RemoteModelConfig, RemoteModelFacts, RemoteRecentWorkspaceFacts, + RemoteResponse, RemoteRetryClassification, RemoteSessionControlFacts, RemoteSessionKind, + RemoteSessionMetadata, RemoteSessionModelSelection, RemoteSessionStateTracker, + RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, RemoteSessionWorkspaceIdentity, + RemoteTerminalPrewarmRequest, RemoteToolStatus, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, + RemoteWorkspaceFileContent, RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, + RemoteWorkspaceKind, RemoteWorkspaceUpdate, SessionInfo, TrackerEvent, + ACP_SESSION_REQUIRES_ACP_CONTROL_MESSAGE, INVALID_ACP_COMMAND_PARAMS, + REMOTE_CAPABILITY_ACP_REMOTE_CONTROL, REMOTE_FILE_MAX_CHUNK_BYTES, REMOTE_FILE_MAX_READ_BYTES, + UNSUPPORTED_REMOTE_CAPABILITY, }; +use serde::Deserialize; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -564,6 +570,7 @@ struct RecordingDialogHost { generated_turn_id: String, restore_error: bool, submit_outcome: RemoteDialogSubmitOutcome, + session_control: RemoteSessionControlFacts, events: Mutex>, submitted: Mutex>>, } @@ -579,11 +586,17 @@ impl RecordingDialogHost { session_id: "session-1".to_string(), turn_id: "turn-generated".to_string(), }, + session_control: RemoteSessionControlFacts::native(), events: Mutex::new(Vec::new()), submitted: Mutex::new(None), } } + fn with_session_control(mut self, session_control: RemoteSessionControlFacts) -> Self { + self.session_control = session_control; + self + } + fn with_restore_error(mut self) -> Self { self.restore_error = true; self @@ -689,6 +702,14 @@ impl RemoteDialogRuntimeHost for RecordingDialogHost { self.generated_turn_id.clone() } + async fn remote_session_control(&self, session_id: &str) -> RemoteSessionControlFacts { + self.events + .lock() + .unwrap() + .push(format!("session_control:{session_id}")); + self.session_control.clone() + } + async fn submit_dialog( &self, submission: RemoteDialogResolvedSubmission, @@ -818,9 +839,16 @@ struct RecordingCommandHost { cancel_request: Mutex>, explicit_context_ids: Mutex>, legacy_image_names: Mutex>, + acp_control_enabled: bool, + acp_send_by_request: Mutex>, } impl RecordingCommandHost { + fn with_acp_control(mut self) -> Self { + self.acp_control_enabled = true; + self + } + fn events(&self) -> Vec { self.events.lock().unwrap().clone() } @@ -879,6 +907,7 @@ impl RemoteCommandRuntimeHost for RecordingCommandHost { message_snapshot: None, active_turn: None, model_catalog: Box::new(None), + acp_projection: None, } } @@ -941,6 +970,230 @@ impl RemoteCommandRuntimeHost for RecordingCommandHost { *self.explicit_context_ids.lock().unwrap() = ids.clone(); ids.into_iter().map(|id| format!("explicit:{id}")).collect() } + + async fn handle_acp_control_command(&self, command: &RemoteCommand) -> RemoteResponse { + if !self.acp_control_enabled { + return match command { + RemoteCommand::AcpPermissionRespond { + session_id, + request_id, + .. + } => bitfun_services_integrations::remote_connect::acp_permission_respond_unsupported( + session_id, + request_id.clone(), + ), + RemoteCommand::AcpSendMessage { + session_id, + request_id, + .. + } + | RemoteCommand::AcpCancelTurn { + session_id, + request_id, + .. + } + | RemoteCommand::AcpGetOptions { + session_id, + request_id, + .. + } + | RemoteCommand::AcpSetOption { + session_id, + request_id, + .. + } + | RemoteCommand::AcpGetCommands { + session_id, + request_id, + .. + } + | RemoteCommand::AcpGetPlan { + session_id, + request_id, + .. + } => bitfun_services_integrations::remote_connect::RemoteAcpControlError::unsupported( + session_id.clone(), + request_id.clone(), + ) + .into_response(), + _ => RemoteResponse::Error { + message: "Not an ACP control command".to_string(), + code: None, + }, + }; + } + match command { + RemoteCommand::AcpPermissionRespond { + session_id, + permission_id, + request_id, + .. + } => { + self.events + .lock() + .unwrap() + .push("acp_permission_respond".to_string()); + bitfun_services_integrations::remote_connect::acp_permission_respond_response(Ok( + bitfun_services_integrations::remote_connect::RemoteAcpPermissionRespondOutcome { + session_id: session_id.clone(), + permission_id: permission_id.clone(), + request_id: request_id.clone(), + resolved: true, + }, + )) + } + RemoteCommand::AcpSendMessage { + session_id, + request_id, + .. + } => { + if let Some(request_id) = request_id.as_deref() { + let key = format!("{session_id}\0{request_id}"); + if let Some(turn_id) = + self.acp_send_by_request.lock().unwrap().get(&key).cloned() + { + return bitfun_services_integrations::remote_connect::acp_send_response( + Ok( + bitfun_services_integrations::remote_connect::RemoteAcpSendOutcome { + session_id: session_id.clone(), + turn_id, + request_id: Some(request_id.to_string()), + }, + ), + ); + } + self.acp_send_by_request + .lock() + .unwrap() + .insert(key, "turn-acp".to_string()); + } + self.events.lock().unwrap().push("acp_send".to_string()); + bitfun_services_integrations::remote_connect::acp_send_response(Ok( + bitfun_services_integrations::remote_connect::RemoteAcpSendOutcome { + session_id: session_id.clone(), + turn_id: "turn-acp".to_string(), + request_id: request_id.clone(), + }, + )) + } + RemoteCommand::AcpCancelTurn { + session_id, + turn_id, + request_id, + } => { + self.events.lock().unwrap().push("acp_cancel".to_string()); + bitfun_services_integrations::remote_connect::acp_cancel_response(Ok( + bitfun_services_integrations::remote_connect::RemoteAcpCancelOutcome { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + request_id: request_id.clone(), + }, + )) + } + RemoteCommand::AcpGetOptions { + session_id, + request_id, + } => { + self.events + .lock() + .unwrap() + .push("acp_get_options".to_string()); + bitfun_services_integrations::remote_connect::acp_options_response(Ok( + bitfun_services_integrations::remote_connect::RemoteAcpOptionsOutcome { + session_id: session_id.clone(), + request_id: request_id.clone(), + options: serde_json::json!({"currentModelId": "m1"}), + }, + )) + } + RemoteCommand::AcpSetOption { + session_id, + request_id, + .. + } => { + self.events + .lock() + .unwrap() + .push("acp_set_option".to_string()); + bitfun_services_integrations::remote_connect::acp_options_response(Ok( + bitfun_services_integrations::remote_connect::RemoteAcpOptionsOutcome { + session_id: session_id.clone(), + request_id: request_id.clone(), + options: serde_json::json!({"currentModelId": "m2"}), + }, + )) + } + RemoteCommand::AcpGetCommands { + session_id, + request_id, + } => { + self.events + .lock() + .unwrap() + .push("acp_get_commands".to_string()); + bitfun_services_integrations::remote_connect::acp_commands_response(Ok( + bitfun_services_integrations::remote_connect::RemoteAcpCommandsOutcome { + session_id: session_id.clone(), + request_id: request_id.clone(), + commands: serde_json::json!([{"name": "compact"}]), + version: 1, + }, + )) + } + RemoteCommand::AcpGetPlan { + session_id, + request_id, + } => { + self.events.lock().unwrap().push("acp_get_plan".to_string()); + bitfun_services_integrations::remote_connect::acp_plan_response(Ok( + bitfun_services_integrations::remote_connect::RemoteAcpPlanOutcome { + session_id: session_id.clone(), + request_id: request_id.clone(), + entries: serde_json::json!([{"content": "step"}]), + version: 2, + }, + )) + } + _ => RemoteResponse::Error { + message: "Not an ACP control command".to_string(), + code: None, + }, + } + } + + async fn reject_native_session_control_for_acp( + &self, + session_id: &str, + command_name: &str, + ) -> Option { + if !self.acp_control_enabled || session_id != "acp-1" { + return None; + } + Some( + bitfun_services_integrations::remote_connect::acp_native_session_control_unsupported( + session_id, + command_name, + ), + ) + } + + async fn reject_native_tool_interaction_for_acp( + &self, + session_id: Option<&str>, + tool_id: &str, + ) -> Option { + if !self.acp_control_enabled { + return None; + } + if session_id == Some("acp-1") || tool_id.starts_with("acp_permission_") { + return Some( + bitfun_services_integrations::remote_connect::acp_native_tool_interaction_unsupported( + session_id, tool_id, + ), + ); + } + None + } } #[tokio::test] @@ -1100,6 +1353,7 @@ async fn remote_connect_dialog_runtime_owns_restore_prewarm_and_submit_order() { "session_exists:session-1", "restore:session-1:D:/workspace/project::", "prewarm:session-1:D:/workspace/project", + "session_control:session-1", "generate_turn", "submit:session-1", ] @@ -1158,6 +1412,7 @@ async fn remote_connect_dialog_runtime_preserves_remote_workspace_identity() { "session_exists:session-1", "restore:session-1:/home/wsp/project:ssh-1:dev-host", "prewarm:session-1:/home/wsp/project", + "session_control:session-1", "submit:session-1", ] ); @@ -1209,6 +1464,7 @@ async fn remote_connect_dialog_runtime_preserves_explicit_turn_without_restore() "resolve_workspace:session-1", "session_exists:session-1", "prewarm:session-1:D:/workspace/project", + "session_control:session-1", "submit:session-1", ] ); @@ -1269,6 +1525,7 @@ async fn remote_connect_dialog_runtime_keeps_legacy_restore_failure_tolerance() "session_exists:session-1", "restore:session-1:D:/workspace/project::", "prewarm:session-1:D:/workspace/project", + "session_control:session-1", "submit:session-1", ] ); @@ -1603,7 +1860,8 @@ async fn remote_connect_file_command_handler_owns_owner_flow_and_uses_host_root( assert_eq!( error, RemoteResponse::Error { - message: "Unsupported remote workspace file command".to_string() + message: "Unsupported remote workspace file command".to_string(), + code: None, } ); @@ -1657,6 +1915,7 @@ fn remote_connect_execution_response_helpers_preserve_wire_shape() { remote_answer_question_response(Err("question closed".to_string())), RemoteResponse::Error { message: "question closed".to_string(), + code: None, } ); } @@ -1765,6 +2024,8 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { created_at_ms: 1_700_000_000_000, last_active_at_ms: 1_700_000_001_000, turn_count: 3, + session_kind: RemoteSessionKind::Native, + capabilities: Vec::new(), }, RemoteSessionMetadata { session_id: "session-2".to_string(), @@ -1773,6 +2034,8 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { created_at_ms: 1_700_000_002_000, last_active_at_ms: 1_700_000_003_000, turn_count: 5, + session_kind: RemoteSessionKind::Native, + capabilities: Vec::new(), }, RemoteSessionMetadata { session_id: "session-3".to_string(), @@ -1781,6 +2044,8 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { created_at_ms: 1_700_000_004_000, last_active_at_ms: 1_700_000_005_000, turn_count: 8, + session_kind: RemoteSessionKind::Native, + capabilities: Vec::new(), }, ]; @@ -1907,6 +2172,574 @@ fn remote_connect_agent_type_mapping_preserves_current_mobile_aliases() { assert_eq!(resolve_remote_agent_type(None), "agentic"); } +#[test] +fn remote_session_info_preserves_kind_and_does_not_treat_legacy_payloads_as_native() { + let acp = RemoteSessionMetadata { + session_id: "acp-1".to_string(), + name: "ACP".to_string(), + agent_type: "gemini".to_string(), + created_at_ms: 1_000, + last_active_at_ms: 2_000, + turn_count: 1, + session_kind: RemoteSessionKind::Acp, + capabilities: Vec::new(), + }; + let info = remote_session_info(&acp, None, None); + assert_eq!(info.session_kind, RemoteSessionKind::Acp); + let json = serde_json::to_value(&info).expect("serialize session info"); + assert_eq!(json["session_kind"], "acp"); + assert!(json.get("capabilities").is_none()); + + let legacy: SessionInfo = serde_json::from_value(serde_json::json!({ + "session_id": "old", + "name": "legacy", + "agent_type": "agentic", + "created_at": "1", + "updated_at": "2", + "message_count": 0 + })) + .expect("legacy SessionInfo"); + assert_eq!(legacy.session_kind, RemoteSessionKind::Unknown); + assert!(legacy.capabilities.is_empty()); +} + +#[test] +fn native_send_message_to_acp_session_is_always_unsupported() { + let rejected = reject_native_dialog_for_session(&RemoteSessionControlFacts::acp(Vec::new())) + .expect("ACP session must fail loud for native send"); + assert_eq!(rejected.code, UNSUPPORTED_REMOTE_CAPABILITY); + assert_eq!(rejected.message, ACP_SESSION_REQUIRES_ACP_CONTROL_MESSAGE); + assert!(reject_native_dialog_for_session(&RemoteSessionControlFacts::native()).is_none()); + assert!(reject_native_dialog_for_session(&RemoteSessionControlFacts::unknown()).is_none()); + // Advertising acp_remote_control must not reopen the native dialog path. + assert!( + reject_native_dialog_for_session(&RemoteSessionControlFacts::acp(vec![ + REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string() + ])) + .is_some() + ); +} + +#[tokio::test] +async fn submit_remote_dialog_fails_loud_for_acp_session_without_capability() { + let host = RecordingDialogHost::new(true, Some("D:/workspace/project")) + .with_session_control(RemoteSessionControlFacts::acp(Vec::new())); + + let error = submit_remote_dialog( + &host, + RemoteDialogSubmissionRequest { + session_id: "session-1".to_string(), + content: "hello".to_string(), + agent_type: Some("code".to_string()), + image_contexts: Vec::::new(), + policy: RemoteDialogSubmissionPolicy::for_source(RemoteConnectSubmissionSource::Relay), + turn_id: Some("turn-1".to_string()), + }, + ) + .await + .expect_err("ACP native send must fail"); + + assert!(error.contains(UNSUPPORTED_REMOTE_CAPABILITY)); + assert!(error.contains(ACP_SESSION_REQUIRES_ACP_CONTROL_MESSAGE)); + assert!(host + .events() + .iter() + .all(|event| !event.starts_with("submit:"))); +} + +#[tokio::test] +async fn new_host_returns_unsupported_for_acp_commands_until_capability_exists() { + let host = RecordingCommandHost::default(); + let response = handle_remote_command( + &host, + &RemoteCommand::AcpSendMessage { + session_id: "acp-1".to_string(), + content: "hello".to_string(), + images: None, + image_contexts: None, + request_id: None, + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + + assert_eq!( + response, + RemoteResponse::AcpCommandError { + session_id: "acp-1".to_string(), + capability: REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string(), + request_id: None, + retry: RemoteRetryClassification::Terminal, + code: UNSUPPORTED_REMOTE_CAPABILITY.to_string(), + message: format!("{ACP_SESSION_REQUIRES_ACP_CONTROL_MESSAGE} (session=acp-1)"), + } + ); + assert!(host.events().is_empty()); +} + +#[test] +fn unknown_acp_command_is_structured_unsupported_not_native_send() { + let parsed = parse_remote_command(serde_json::json!({ + "cmd": "acp_future_widget", + "session_id": "acp-1" + })); + let error = parsed.expect_err("future ACP commands must not parse as native"); + let response = error.into_remote_response(); + match response { + RemoteResponse::Error { message, code } => { + assert_eq!(code.as_deref(), Some(UNSUPPORTED_REMOTE_CAPABILITY)); + assert!(message.contains("acp_future_widget")); + } + other => panic!("expected error response, got {other:?}"), + } +} + +#[test] +fn known_acp_command_with_bad_payload_is_invalid_params_not_unsupported() { + let parsed = parse_remote_command(serde_json::json!({ + "cmd": "acp_set_option", + "session_id": "acp-1" + // missing required config_id / value + })); + let error = parsed.expect_err("known ACP command with bad payload must not look unsupported"); + let response = error.into_remote_response(); + match response { + RemoteResponse::Error { message, code } => { + assert_eq!(code.as_deref(), Some(INVALID_ACP_COMMAND_PARAMS)); + assert!(message.contains("acp_set_option")); + assert!( + !message.to_lowercase().contains("host does not support"), + "bad payload must not reuse old-host unsupported copy: {message}" + ); + } + other => panic!("expected error response, got {other:?}"), + } +} + +#[test] +fn every_acp_command_variant_is_classified_as_known() { + // `parse_remote_command` keeps a hand-written list of known ACP command + // names. A new `Acp*` variant that nobody adds to that list would report a + // bad payload as "host does not support this command", sending the phone to + // the wrong remediation. Recover the real variant names from serde's own + // unknown-variant error so the list cannot drift away from the enum. + let error = serde_json::from_value::(serde_json::json!({ + "cmd": "acp_zzz_not_a_command" + })) + .expect_err("sentinel must not parse"); + let text = error.to_string(); + let (_, expected) = text + .split_once("expected one of ") + .unwrap_or_else(|| panic!("serde no longer lists variants: {text}")); + let acp_variants: Vec = expected + .split('`') + .filter(|item| item.starts_with("acp_")) + .map(|item| item.to_string()) + .collect(); + assert!( + acp_variants.len() >= 7, + "expected the ACP command family, found {acp_variants:?}" + ); + + for variant in acp_variants { + // Every ACP variant requires `session_id`, so a bare `cmd` is always a + // parameter error — never an unknown command. + let error = match parse_remote_command(serde_json::json!({ "cmd": variant.clone() })) { + Ok(_) => panic!("{variant} with no params must not parse"), + Err(error) => error, + }; + match error.into_remote_response() { + RemoteResponse::Error { code, .. } => assert_eq!( + code.as_deref(), + Some(INVALID_ACP_COMMAND_PARAMS), + "{variant} is missing from the known ACP command list" + ), + other => panic!("expected error response for {variant}, got {other:?}"), + } + } +} + +#[test] +fn old_host_command_enum_rejects_acp_send_as_unknown_not_send_message() { + #[derive(Debug, Deserialize)] + #[serde(tag = "cmd", rename_all = "snake_case")] + enum LegacyRemoteCommand { + SendMessage { session_id: String, content: String }, + } + + let value = serde_json::json!({ + "cmd": "acp_send_message", + "session_id": "acp-1", + "content": "hello" + }); + assert!(serde_json::from_value::(value.clone()).is_err()); + let parsed = parse_remote_command(value).expect("current host recognizes acp_send_message"); + assert!(matches!(parsed, RemoteCommand::AcpSendMessage { .. })); +} + +#[test] +fn new_host_parses_full_acp_command_family_with_request_ids() { + let commands = [ + serde_json::json!({ + "cmd": "acp_get_options", + "session_id": "acp-1", + "request_id": "req-1" + }), + serde_json::json!({ + "cmd": "acp_set_option", + "session_id": "acp-1", + "config_id": "model", + "value": {"type": "select", "value": "fast"}, + "request_id": "req-2" + }), + serde_json::json!({ + "cmd": "acp_get_commands", + "session_id": "acp-1", + "request_id": "req-3" + }), + serde_json::json!({ + "cmd": "acp_get_plan", + "session_id": "acp-1", + "request_id": "req-4" + }), + serde_json::json!({ + "cmd": "acp_permission_respond", + "session_id": "acp-1", + "permission_id": "acp_permission_1", + "option_id": "allow-once", + "request_id": "req-5" + }), + ]; + for value in commands { + parse_remote_command(value).expect("new ACP command must parse on current host"); + } +} + +#[test] +fn old_host_rejects_new_acp_commands_as_unknown() { + #[derive(Debug, Deserialize)] + #[serde(tag = "cmd", rename_all = "snake_case")] + enum LegacyRemoteCommand { + AcpSendMessage { + session_id: String, + content: String, + }, + AcpCancelTurn { + session_id: String, + turn_id: Option, + }, + } + + for cmd in [ + "acp_get_options", + "acp_set_option", + "acp_get_commands", + "acp_get_plan", + "acp_permission_respond", + ] { + let value = serde_json::json!({ + "cmd": cmd, + "session_id": "acp-1", + "permission_id": "acp_permission_1", + "option_id": "allow-once", + "config_id": "model", + "value": "fast" + }); + assert!( + serde_json::from_value::(value.clone()).is_err(), + "{cmd} must stay unknown to old hosts" + ); + parse_remote_command(value).expect("new host still parses {cmd}"); + } +} + +#[test] +fn acp_permission_respond_signature_rejects_native_tool_id_fields() { + let parsed = parse_remote_command(serde_json::json!({ + "cmd": "acp_permission_respond", + "session_id": "acp-1", + "permission_id": "acp_permission_1", + "option_id": "allow-once", + "tool_id": "native-tool-1" + })) + .expect("extra tool_id must be ignored by serde, not accepted as identity"); + match parsed { + RemoteCommand::AcpPermissionRespond { + permission_id, + option_id, + .. + } => { + assert_eq!(permission_id, "acp_permission_1"); + assert_eq!(option_id, "allow-once"); + } + other => panic!("expected AcpPermissionRespond, got {other:?}"), + } +} + +#[tokio::test] +async fn acp_permission_respond_unsupported_without_control_host() { + let host = RecordingCommandHost::default(); + let response = handle_remote_command( + &host, + &RemoteCommand::AcpPermissionRespond { + session_id: "acp-1".to_string(), + permission_id: "acp_permission_1".to_string(), + option_id: "allow-once".to_string(), + request_id: Some("req-perm".to_string()), + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + match response { + RemoteResponse::AcpCommandError { + session_id, + code, + message, + .. + } => { + assert_eq!(session_id, "acp-1"); + assert_eq!(code, UNSUPPORTED_REMOTE_CAPABILITY); + assert!(message.contains("mailbox") || message.contains("ACP")); + } + other => panic!("expected AcpCommandError, got {other:?}"), + } +} + +#[tokio::test] +async fn acp_permission_respond_routes_when_mailbox_host_enabled() { + let host = RecordingCommandHost::default().with_acp_control(); + let response = handle_remote_command( + &host, + &RemoteCommand::AcpPermissionRespond { + session_id: "acp-1".to_string(), + permission_id: "acp_permission_1".to_string(), + option_id: "allow-once".to_string(), + request_id: Some("req-perm".to_string()), + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + match response { + RemoteResponse::AcpPermissionResolved { + session_id, + permission_id, + resolved, + request_id, + .. + } => { + assert_eq!(session_id, "acp-1"); + assert_eq!(permission_id, "acp_permission_1"); + assert!(resolved); + assert_eq!(request_id.as_deref(), Some("req-perm")); + } + other => panic!("expected AcpPermissionResolved, got {other:?}"), + } +} + +#[test] +fn acp_permission_mailbox_survives_without_clear_on_list() { + let mailbox = AcpPermissionMailbox::default(); + mailbox.insert(AcpPermissionMailboxEntry { + permission_id: "acp_permission_1".to_string(), + session_id: "acp-1".to_string(), + tool_call: serde_json::json!({"toolCallId": "tool-1"}), + options: serde_json::json!([]), + created_at_ms: 1, + expires_at_ms: 2, + }); + // Disconnect must not clear pending: listing does not remove. + assert_eq!(mailbox.list_for_session("acp-1").len(), 1); + assert!(mailbox.contains("acp_permission_1")); +} + +#[tokio::test] +async fn recording_host_routes_acp_send_cancel_options_commands_plan() { + let host = RecordingCommandHost::default().with_acp_control(); + let send = handle_remote_command( + &host, + &RemoteCommand::AcpSendMessage { + session_id: "acp-1".to_string(), + content: "hello".to_string(), + images: None, + image_contexts: None, + request_id: Some("req-send".to_string()), + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + assert!(matches!( + send, + RemoteResponse::AcpMessageSent { + session_id, + turn_id, + request_id: Some(ref id), + .. + } if session_id == "acp-1" && turn_id == "turn-acp" && id == "req-send" + )); + + let cancel = handle_remote_command( + &host, + &RemoteCommand::AcpCancelTurn { + session_id: "acp-1".to_string(), + turn_id: Some("turn-acp".to_string()), + request_id: None, + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + assert!(matches!(cancel, RemoteResponse::AcpTurnCancelled { .. })); + + let options = handle_remote_command( + &host, + &RemoteCommand::AcpGetOptions { + session_id: "acp-1".to_string(), + request_id: None, + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + assert!(matches!(options, RemoteResponse::AcpOptions { .. })); + + let set = handle_remote_command( + &host, + &RemoteCommand::AcpSetOption { + session_id: "acp-1".to_string(), + config_id: "mode".to_string(), + value: serde_json::json!({"type": "select", "value": "default"}), + request_id: None, + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + assert!(matches!(set, RemoteResponse::AcpOptions { .. })); + + let commands = handle_remote_command( + &host, + &RemoteCommand::AcpGetCommands { + session_id: "acp-1".to_string(), + request_id: None, + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + assert!(matches!( + commands, + RemoteResponse::AcpCommands { version: 1, .. } + )); + + let plan = handle_remote_command( + &host, + &RemoteCommand::AcpGetPlan { + session_id: "acp-1".to_string(), + request_id: None, + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + assert!(matches!(plan, RemoteResponse::AcpPlan { version: 2, .. })); + + let events = host.events(); + assert!(events.iter().any(|event| event == "acp_send")); + assert!(events.iter().any(|event| event == "acp_cancel")); + assert!(events.iter().any(|event| event == "acp_get_options")); + assert!(events.iter().any(|event| event == "acp_set_option")); + assert!(events.iter().any(|event| event == "acp_get_commands")); + assert!(events.iter().any(|event| event == "acp_get_plan")); +} + +#[tokio::test] +async fn acp_send_message_request_id_is_idempotent() { + // Wire-path coverage through RecordingCommandHost only. Concurrent claim + // semantics for DesktopRemoteAcpControlHost live in + // `acp_request_idempotency` (desktop lib tests). + let host = RecordingCommandHost::default().with_acp_control(); + let command = RemoteCommand::AcpSendMessage { + session_id: "acp-1".to_string(), + content: "hello".to_string(), + images: None, + image_contexts: None, + request_id: Some("req-dup".to_string()), + }; + let first = handle_remote_command(&host, &command, RemoteConnectSubmissionSource::Relay).await; + let second = handle_remote_command(&host, &command, RemoteConnectSubmissionSource::Relay).await; + assert_eq!(first, second); + assert_eq!( + host.events() + .iter() + .filter(|event| event.as_str() == "acp_send") + .count(), + 1, + "duplicate request_id must not open a second turn" + ); +} + +#[tokio::test] +async fn native_confirm_tool_on_acp_session_is_unsupported() { + let host = RecordingCommandHost::default().with_acp_control(); + let response = handle_remote_command( + &host, + &RemoteCommand::ConfirmTool { + tool_id: "tool-1".to_string(), + session_id: Some("acp-1".to_string()), + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + match response { + RemoteResponse::Error { message, code } => { + assert_eq!(code.as_deref(), Some(UNSUPPORTED_REMOTE_CAPABILITY)); + assert!(message.contains("ACP")); + assert!(message.contains("tool-1")); + } + other => panic!("expected unsupported error, got {other:?}"), + } + assert!(host.events().iter().all(|event| event != "interaction")); +} + +#[tokio::test] +async fn native_confirm_tool_rejects_acp_permission_id_without_session() { + let host = RecordingCommandHost::default().with_acp_control(); + let response = handle_remote_command( + &host, + &RemoteCommand::ConfirmTool { + tool_id: "acp_permission_abc".to_string(), + session_id: None, + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + match response { + RemoteResponse::Error { message, code } => { + assert_eq!(code.as_deref(), Some(UNSUPPORTED_REMOTE_CAPABILITY)); + assert!(message.contains("acp_permission_abc")); + } + other => panic!("expected unsupported error, got {other:?}"), + } +} + +#[test] +fn remote_session_info_can_advertise_acp_remote_control() { + let info = remote_session_info( + &RemoteSessionMetadata { + session_id: "acp-1".to_string(), + name: "ACP".to_string(), + agent_type: "acp:gemini".to_string(), + created_at_ms: 1, + last_active_at_ms: 2, + turn_count: 0, + session_kind: RemoteSessionKind::Acp, + capabilities: vec![REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string()], + }, + None, + None, + ); + assert_eq!(info.session_kind, RemoteSessionKind::Acp); + assert_eq!( + info.capabilities, + vec![REMOTE_CAPABILITY_ACP_REMOTE_CONTROL.to_string()] + ); +} + #[test] fn remote_connect_message_dtos_keep_current_wire_shape() { let image = ImageAttachment { @@ -2124,6 +2957,7 @@ fn remote_connect_response_wire_shape_lives_in_owner_contract() { message_snapshot: None, active_turn: Some(active_turn), model_catalog: Box::new(Some(sample_remote_model_catalog(11))), + acp_projection: None, }) .expect("serialize poll response"); @@ -2158,6 +2992,31 @@ fn remote_connect_response_wire_shape_lives_in_owner_contract() { assert_eq!(title_updated["title"], "Renamed session"); } +#[test] +fn legacy_session_poll_without_acp_projection_remains_readable() { + let response: RemoteResponse = serde_json::from_value(serde_json::json!({ + "resp": "session_poll", + "version": 3, + "changed": false, + "model_catalog": null + })) + .expect("legacy SessionPoll should deserialize"); + + match response { + RemoteResponse::SessionPoll { + version, + changed, + acp_projection, + .. + } => { + assert_eq!(version, 3); + assert!(!changed); + assert!(acp_projection.is_none()); + } + other => panic!("expected SessionPoll, got {other:?}"), + } +} + fn sample_remote_model_catalog(version: u64) -> RemoteModelCatalog { RemoteModelCatalog { version, @@ -2417,8 +3276,11 @@ fn remote_connect_tracker_preserves_streaming_snapshot_contract() { round_id: "round-1".to_string(), round_group_id: None, round_index: 3, - model_config_id: "model-config".to_string(), - effective_model_name: "provider-model".to_string(), + identity: bitfun_events::ModelRoundIdentity::Native { + model_config_id: "model-config".to_string(), + effective_model_name: "provider-model".to_string(), + }, + render_hints: None, }); tracker.handle_agentic_event(&AgenticEvent::ThinkingChunk { session_id: "session-1".to_string(), @@ -2457,6 +3319,72 @@ fn remote_connect_tracker_preserves_streaming_snapshot_contract() { assert_eq!(items[1].content.as_deref(), Some("answer")); } +#[test] +fn remote_connect_tracker_projects_acp_metadata_with_independent_versions() { + let tracker = RemoteSessionStateTracker::new("session-acp".to_string()); + + tracker.handle_agentic_event(&AgenticEvent::AcpContextUsageUpdated { + session_id: "session-acp".to_string(), + turn_id: "turn-1".to_string(), + client_id: "claude-code".to_string(), + used: 512, + size: 4096, + cost: Some(serde_json::json!({ "amount": 1.25, "currency": "USD" })), + }); + tracker.handle_agentic_event(&AgenticEvent::AcpAvailableCommandsUpdated { + session_id: "session-acp".to_string(), + client_id: "claude-code".to_string(), + commands: vec![AcpAvailableCommandFact { + name: "review".to_string(), + description: "Review changes".to_string(), + input_hint: Some("[path]".to_string()), + }], + }); + tracker.handle_agentic_event(&AgenticEvent::AcpPlanUpdated { + session_id: "session-acp".to_string(), + turn_id: "turn-1".to_string(), + client_id: "claude-code".to_string(), + entries: vec![AcpPlanEntryFact { + content: "Inspect files".to_string(), + priority: "high".to_string(), + status: "in_progress".to_string(), + }], + }); + tracker.handle_agentic_event(&AgenticEvent::AcpSessionOptionsChanged { + session_id: "session-acp".to_string(), + client_id: "claude-code".to_string(), + }); + tracker.handle_agentic_event(&AgenticEvent::AcpAvailableCommandsUpdated { + session_id: "session-acp".to_string(), + client_id: "claude-code".to_string(), + commands: Vec::new(), + }); + + assert_eq!(tracker.version(), 5); + let projection = tracker + .snapshot_acp_projection() + .expect("ACP projection snapshot"); + assert_eq!(projection.context_usage.as_ref().unwrap().version, 1); + assert_eq!(projection.available_commands.as_ref().unwrap().version, 2); + assert_eq!(projection.plan.as_ref().unwrap().version, 1); + assert_eq!(projection.session_options.as_ref().unwrap().version, 1); + + let poll = serde_json::to_value(remote_snapshot_poll_response(&tracker, 77, None)) + .expect("serialize ACP poll projection"); + assert_eq!(poll["version"], 77); + assert_eq!(poll["acp_projection"]["context_usage"]["version"], 1); + assert_eq!(poll["acp_projection"]["available_commands"]["version"], 2); + assert_eq!( + poll["acp_projection"]["plan"]["entries"][0]["content"], + "Inspect files" + ); + assert_eq!(poll["acp_projection"]["session_options"]["version"], 1); + + let no_change = + serde_json::to_value(remote_no_change_poll_response(77)).expect("serialize no-change poll"); + assert!(no_change.get("acp_projection").is_none()); +} + #[test] fn remote_connect_tracker_keeps_subagent_items_out_of_parent_accumulators() { let tracker = RemoteSessionStateTracker::new("parent-session".to_string()); @@ -2821,3 +3749,149 @@ fn remote_connect_tool_preview_slimming_keeps_short_fields_and_drops_large_strin assert!(make_slim_tool_params(&serde_json::json!(42)).is_none()); } + +/// §9.2 negative requirement: an ACP session must not be reachable through the +/// *native* control surface. Native `cancel_task` talks to the native +/// scheduler and native `set_session_model` writes native session metadata — +/// admitting an externally projected ACP session into either is the "误做了 C" +/// failure §13 asks us to detect. The read-only half (`get_model_catalog`, +/// `get_session_messages`) must stay open so the phone can still render the +/// session as observable. +#[tokio::test] +async fn acp_sessions_reject_native_cancel_and_model_selection() { + let host = RecordingCommandHost::default().with_acp_control(); + + for (command, expected_name) in [ + ( + RemoteCommand::CancelTask { + session_id: "acp-1".to_string(), + turn_id: Some("turn-1".to_string()), + }, + "cancel_task", + ), + ( + RemoteCommand::SetSessionModel { + session_id: "acp-1".to_string(), + model_id: "sonnet".to_string(), + reasoning_preset: None, + }, + "set_session_model", + ), + ] { + let response = + handle_remote_command(&host, &command, RemoteConnectSubmissionSource::Relay).await; + match response { + RemoteResponse::Error { code, message } => { + assert_eq!(code.as_deref(), Some(UNSUPPORTED_REMOTE_CAPABILITY)); + assert!( + message.contains(expected_name) && message.contains("acp-1"), + "{expected_name} rejection must name the command and session: {message}" + ); + } + other => panic!("expected a fail-loud rejection for {expected_name}, got {other:?}"), + } + } + + // Nothing reached the native runtime. + assert!( + host.events().is_empty(), + "no native handler may run for an ACP session: {:?}", + host.events() + ); + + // Reading the catalog is still allowed — "observable, not selectable". + let catalog = handle_remote_command( + &host, + &RemoteCommand::GetModelCatalog { + session_id: Some("acp-1".to_string()), + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + assert!(matches!(catalog, RemoteResponse::SessionCreated { .. })); + assert_eq!(host.events(), vec!["session"]); +} + +/// Native sessions must not pay for the ACP guard: the same two commands keep +/// reaching the native runtime unchanged. +#[tokio::test] +async fn native_sessions_keep_cancel_and_model_selection() { + let host = RecordingCommandHost::default().with_acp_control(); + + let cancelled = handle_remote_command( + &host, + &RemoteCommand::CancelTask { + session_id: "native-1".to_string(), + turn_id: Some("turn-9".to_string()), + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + assert!(matches!(cancelled, RemoteResponse::TaskCancelled { .. })); + assert_eq!(host.cancel_request().session_id, "native-1"); + + let model = handle_remote_command( + &host, + &RemoteCommand::SetSessionModel { + session_id: "native-1".to_string(), + model_id: "sonnet".to_string(), + reasoning_preset: None, + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + assert!(matches!(model, RemoteResponse::SessionCreated { .. })); + assert_eq!(host.events(), vec!["cancel", "session"]); +} + +/// `cancel_tool` and `answer_question` carry no `session_id` on the wire, so +/// the only guard that can fire is the ACP permission-id one — and it must. +/// They used to fall into the dispatch's `_ => (None, "")` bucket, which +/// skipped the check entirely and let a native interaction address an ACP +/// permission id. +#[tokio::test] +async fn acp_permission_ids_reject_native_cancel_tool_and_answer_question() { + let host = RecordingCommandHost::default().with_acp_control(); + + for command in [ + RemoteCommand::CancelTool { + tool_id: "acp_permission_7".to_string(), + reason: None, + }, + RemoteCommand::AnswerQuestion { + tool_id: "acp_permission_7".to_string(), + answers: serde_json::json!({"choice": "allow"}), + }, + ] { + let response = + handle_remote_command(&host, &command, RemoteConnectSubmissionSource::Relay).await; + match response { + RemoteResponse::Error { code, message } => { + assert_eq!(code.as_deref(), Some(UNSUPPORTED_REMOTE_CAPABILITY)); + assert!(message.contains("acp_permission_7"), "{message}"); + } + other => panic!("expected a fail-loud rejection, got {other:?}"), + } + } + assert!( + host.events().is_empty(), + "native interaction handler must not run: {:?}", + host.events() + ); + + // A plain native tool id is untouched. + let accepted = handle_remote_command( + &host, + &RemoteCommand::CancelTool { + tool_id: "tool-1".to_string(), + reason: None, + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + assert!(matches!( + accepted, + RemoteResponse::InteractionAccepted { .. } + )); + assert_eq!(host.events(), vec!["interaction"]); +} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts index 480efdd6c0..ea38ec0e90 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts @@ -9,7 +9,9 @@ import { globalEventBus, PERMISSION_REQUEST_NOTIFICATION_EVENT, } from '@/infrastructure/event-bus'; +import { createLogger } from '@/shared/utils/logger'; +const log = createLogger('AcpPermissionToolCard'); const pendingAcpPermissionRequests = new Map(); function acpPermissionToolId(event: AcpPermissionRequestEvent): string | null { @@ -96,6 +98,23 @@ export function handleAcpPermissionRequestForToolCard(event: AcpPermissionReques return true; } +/** Re-read shared mailbox after page refresh / session hydrate. */ +export async function rehydrateAcpPermissionsFromMailbox(sessionId: string): Promise { + if (!sessionId) { + return; + } + try { + const { ACPClientAPI } = await import('@/infrastructure/api/service-api/ACPClientAPI'); + const pending = await ACPClientAPI.listPendingPermissions(sessionId); + for (const event of pending) { + handleAcpPermissionRequestForToolCard(event); + } + } catch (error) { + // Mailbox may be unavailable on older hosts; surface is forward-compatible. + log.warn('Failed to rehydrate ACP permissions from mailbox', { sessionId, error }); + } +} + export function applyPendingAcpPermissionForTool( store: FlowChatStore, toolId: string diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index deae59d0d0..83e688b53c 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -1182,6 +1182,48 @@ describe('handleModelRoundStart', () => { expect(turn?.modelRounds[0]?.effectiveModelName).toBeUndefined(); }); + it('stores external ACP model identity without synthesizing native keys', async () => { + createSessionWithTurn({ + id: 'turn-1', + sessionId: 'session-1', + userMessage: { + id: 'user-1', + content: 'Initial request', + timestamp: 900, + }, + modelRounds: [], + status: 'processing', + startTime: 900, + }); + await startStreamingMachine(); + const context = createFlowChatContext(); + + __test_only__.handleModelRoundStart(context, { + sessionId: 'session-1', + turnId: 'turn-1', + roundId: 'round-1', + roundIndex: 0, + externalModel: { + provider: 'acp', + clientId: 'gemini', + modelId: 'gemini-2.5', + }, + } as any); + + const turn = FlowChatStore.getInstance() + .getState() + .sessions.get('session-1') + ?.dialogTurns.find(item => item.id === 'turn-1'); + + expect(turn?.modelRounds[0]?.externalModel).toEqual({ + provider: 'acp', + clientId: 'gemini', + modelId: 'gemini-2.5', + }); + expect(turn?.modelRounds[0]?.modelConfigId).toBeUndefined(); + expect(turn?.modelRounds[0]?.effectiveModelName).toBeUndefined(); + }); + it('trims and stores model identity fields when present', async () => { createSessionWithTurn({ id: 'turn-1', diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 9768bd9484..d7bd8d4987 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -2198,6 +2198,7 @@ function handleModelRoundStart(context: FlowChatContext, event: ModelRoundStarte // Model identity is optional: external ACP agents carry none. ...(event.modelConfigId ? { modelConfigId: event.modelConfigId.trim() } : {}), ...(event.effectiveModelName ? { effectiveModelName: event.effectiveModelName.trim() } : {}), + ...(event.externalModel ? { externalModel: event.externalModel } : {}), ...(disableExploreGrouping ? { renderHints: { disableExploreGrouping: true } } : {}), diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts index a103610810..29b75a11ad 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts @@ -358,6 +358,20 @@ describe('PersistenceModule', () => { expect(mockSaveSessionTurn).not.toHaveBeenCalled(); }); + it('does not become a second transcript writer for ACP turns', async () => { + const turn = createDialogTurn('completed'); + const context = createContext(turn); + const session = context.flowChatStore.getState().sessions.get(SESSION_ID); + session.config.agentType = 'acp:dsh'; + session.mode = 'acp:dsh'; + + await saveDialogTurnToDisk(context, SESSION_ID, TURN_ID); + await flushMicrotasks(); + + expect(mockSaveSessionTurn).not.toHaveBeenCalled(); + expect(mockSaveSessionMetadata).not.toHaveBeenCalled(); + }); + it('defers partial-history saves until a storage identity is available', async () => { const turn = createDialogTurn('completed'); delete turn.storageTurnIndex; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 2b466fa08e..71613654b9 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -14,6 +14,7 @@ import { import { requireSessionProjectWorkspacePath } from '../../utils/sessionWorkspace'; import { resolveSessionDriverId } from '../../session-drivers/resolve'; import { resolveStorageTurnIndex } from '../../utils/flowChatTurnIdentity'; +import { isAcpFlowSession } from '../../utils/acpSession'; const log = createLogger('PersistenceModule'); const COALESCED_IMMEDIATE_SAVE_DELAY_MS = 500; @@ -298,7 +299,15 @@ async function performSaveDialogTurnToDisk( log.debug('Session not found, skipping save', { sessionId, turnId }); return; } - if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) { + if ( + isTransientSession(session) || + isObserverOnlyDispatchSession(sessionId, session) || + isAcpFlowSession(session) + ) { + // ACP turns are externally projected. The Desktop + // AcpDurableProjectionWriter is their single transcript writer; saving + // the same canonical events again from the Web UI creates duplicate + // turns with identical ids. return; } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index af4b0ece73..c97ee66806 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -28,6 +28,7 @@ import type { } from './types'; import type { Session } from '../../types/flow-chat'; import { touchSessionActivity } from './PersistenceModule'; +import { rehydrateAcpPermissionsFromMailbox } from './AcpPermissionToolCardModule'; import { createTextSessionTitleDescriptor, createDefaultSessionTitleDescriptor, @@ -359,6 +360,7 @@ async function hydrateHistoricalSession( startupTrace.markPhase('historical_session_hydrate_request_end', { durationMs: elapsedMs(traceStartedAt), }); + void rehydrateAcpPermissionsFromMailbox(sessionId); } catch (error) { if (isSurfaceChangedError(error)) { throw error; diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 066fdb9d11..11370e8173 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -189,6 +189,13 @@ export interface ModelRoundAttempt { diagnostic?: ModelRoundAttemptDiagnostic; } +export interface ExternalModelIdentity { + provider: string; + clientId: string; + modelId?: string; + displayName?: string; +} + // Model round: output from a single model call. export interface ModelRound { id: string; @@ -206,6 +213,7 @@ export interface ModelRound { providerId?: string; modelConfigId?: string; effectiveModelName?: string; + externalModel?: ExternalModelIdentity; firstChunkMs?: number; firstVisibleOutputMs?: number; streamDurationMs?: number; diff --git a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts index 77bdbdadbb..e6f5b1b8d7 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts @@ -373,6 +373,29 @@ export class ACPClientAPI { return api.invoke('submit_acp_permission_response', { request }); } + static async listPendingPermissions( + sessionId: string + ): Promise { + const entries = await api.invoke>('list_acp_pending_permissions', { + request: { sessionId }, + }); + return (entries ?? []).map((entry) => ({ + permissionId: entry.permissionId, + sessionId: entry.sessionId, + toolCall: entry.toolCall, + options: Array.isArray(entry.options) + ? (entry.options as AcpPermissionOption[]) + : undefined, + })); + } + static async createFlowSession( request: CreateAcpFlowSessionRequest ): Promise { diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 4ed8a675c6..76c8541acf 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -738,6 +738,15 @@ export interface ModelRoundStartedEvent extends AgenticEvent { modelConfigId?: string; /** Provider model name sent on the request. */ effectiveModelName?: string; + externalModel?: { + provider: string; + clientId: string; + modelId?: string; + displayName?: string; + }; + renderHints?: { + disableExploreGrouping?: boolean; + }; } export interface AcpContextUsageUpdatedEvent extends AgenticEvent {