cleanup: remove ACP bypass fallbacks and stale architecture docs

This commit is contained in:
Haitao Pan 2026-04-08 20:48:06 +08:00
parent a669453619
commit 792f7920e9
13 changed files with 53 additions and 720 deletions

View File

@ -5,7 +5,7 @@
> 已过时:本文记录的是 `workspaceRef / workspaceRefKind / cwd fallback` 主导时期的线程目录流转。
>
> 当前实现请优先参考:
> [docs/architecture/assistant-thread-target-model-20260328.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/assistant-thread-target-model-20260328.md)
> [docs/architecture/task-control-plane-unification.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/task-control-plane-unification.md)
>
> 新文档已经把 TaskThread 的主流程图和状态图重画为基于 `workspaceBinding / executionBinding / lifecycleState` 的 Mermaid 版本。

View File

@ -1,67 +0,0 @@
# Assistant TaskThread 信息架构
本文说明线程信息如何围绕 `TaskThread` 进入 UI、进入 controller/runtime 的执行请求构造,再通过统一任务入口回写到 UI。
统一目标规范以
[任务执行链路统一收敛](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-task-control-plane-unification/docs/architecture/task-control-plane-unification.md)
为准。
## 主规则
1. UI 当前选择的是 `TaskThread.threadId`
2. UI 选中线程后读取完整 `TaskThread`
3. 主体区域显示、右栏显示、执行请求构造都围绕同一个 `TaskThread`
4. UI 保持现有结构,但不是线程信息的独立来源
## 信息流转图
```mermaid
flowchart LR
A["UI选择任务线程"] --> B["TaskThread.threadId"]
B --> C["读取 TaskThread"]
C --> D1["ownerScope"]
C --> D2["workspaceBinding"]
C --> D3["executionBinding"]
C --> D4["contextState"]
D1 --> E["构造执行请求"]
D2 --> E
D3 --> E
D4 --> E
E --> F["GoTaskService.executeTask"]
F --> G["ACP Control Plane"]
G --> H{"resolvedExecutionTarget"}
H -->|single-agent| I["single-agent executor"]
H -->|multi-agent| J["multi-agent executor"]
H -->|gateway| K["gateway executor"]
I --> L["执行结果"]
J --> L
K --> L
L --> M["回写线程上下文"]
L --> N["显式更新 workspaceBinding"]
M --> O["主体区域 / 右栏显示"]
N --> O
```
## Current implementation note
- 当前实现中可能仍有 adapter 直连痕迹。
- 这些痕迹不再作为信息架构规范的一部分。
## Target architecture rule
- `读取 TaskThread` 是 UI 与执行层共享的唯一线程信息入口
- `构造执行请求``GoTaskService / runtime` 协调层完成
- 统一入口是 `GoTaskService.executeTask`
- `gateway` 是 ACP 解析出的执行器分支
- `workspaceBinding` 只允许来自 create/load 显式绑定或结构化结果回写
## Compatibility route (temporary)
- 不再定义新的 relay-only 执行协议
- 旧的 direct gateway / direct collaboration 文档口径已废止

View File

@ -1,81 +0,0 @@
# Assistant TaskThread 当前模型2026-03-28
本文保留 `TaskThread` 的当前模型说明,但不再把现有兼容分流描述为长期规范。
统一规范以 [任务执行链路统一收敛](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-task-control-plane-unification/docs/architecture/task-control-plane-unification.md) 为准。
## 当前结论
1. `TaskThread` 是任务线程的唯一主对象。
2. UI 保持现有结构不变,但线程选择的唯一键是 `TaskThread.threadId`
3. UI 选中线程后,系统必须读取完整 `TaskThread`
4. `workspaceBinding` 在 create/load 时必须完整;缺失 binding 的旧记录按非法数据处理并跳过加载。
5. 执行请求由 controller / runtime 根据 `ownerScope / workspaceBinding / executionBinding / contextState` 构造。
6. 当前实现曾存在多条执行链,但目标规范已经收敛到 `UI -> GoTaskService -> ACP -> resolved executor`
7. 执行结果先回写 `TaskThread.contextState`,主体区域同步显示。
8. `contextState` 是线程上下文真相源;`lifecycleState` 只表达生命周期摘要。
## TaskThread 结构
```text
TaskThread
- threadId: String
- title: String
- ownerScope: ThreadOwnerScope
- workspaceBinding: WorkspaceBinding
- executionBinding: ExecutionBinding
- contextState: ThreadContextState
- lifecycleState: ThreadLifecycleState
- createdAtMs: double
- updatedAtMs: double?
```
## 生命周期主链
```mermaid
flowchart LR
A["UI选择任务线程"] --> B["TaskThread.threadId"]
B --> C["读取 TaskThread"]
C --> D1["ownerScope"]
C --> D2["workspaceBinding"]
C --> D3["executionBinding"]
C --> D4["contextState"]
D1 --> E["构造执行请求"]
D2 --> E
D3 --> E
D4 --> E
E --> F["GoTaskService.executeTask"]
F --> G["ACP Control Plane"]
G --> H{"resolvedExecutionTarget"}
H -->|single-agent| I["single-agent executor"]
H -->|multi-agent| J["multi-agent executor"]
H -->|gateway| K["gateway executor"]
I --> L["执行结果"]
J --> L
K --> L
L --> M["回写线程上下文"]
L --> N["显式更新 workspaceBinding"]
M --> O["主体区域 / 右栏显示"]
N --> O
```
## Current implementation note
- 当前仓库仍能看到一些历史分流痕迹。
- 这些实现痕迹不再作为长期规范文档的一部分。
## Target architecture rule
- 目标规范是单一路径:`TaskThread -> GoTaskService -> ACP -> resolved executor`
- `gateway` 是解析后的执行器分支,不是 UI/controller 的规范旁路
## Compatibility route (temporary)
- 历史文档中的 `OpenClaw lane`、`ACP lane` 并列口径已废止
- 若代码中仍出现旧 route只能视为待清理实现遗留

View File

@ -456,11 +456,11 @@ sequenceDiagram
## 12. 与现有文档的关系
- [`/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/assistant-thread-target-model-20260328.md`](file:///Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/assistant-thread-target-model-20260328.md)
- [`/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/task-control-plane-unification.md`](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/task-control-plane-unification.md)
说明当前 `TaskThread` 主模型。
- [`/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/task-thread-session-key-isolation-20260329.md`](file:///Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/task-thread-session-key-isolation-20260329.md)
说明 `sessionKey` 与线程身份隔离约束。
- [`/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/xworkmate-internal-state-architecture.md`](file:///Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/xworkmate-internal-state-architecture.md)
- [`/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/xworkmate-layered-architecture.md`](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/xworkmate-layered-architecture.md)
说明当前内部状态如何围绕 `TaskThread` 组织。
本文在这些基础上进一步上升一层,定义跨设备会话的目标架构。

View File

@ -45,6 +45,20 @@ flowchart TD
Q --> R["UI stream render"]
```
## 端侧桥接规则
### Desktop App
- Desktop App 直接桥接 Go 代码
- Desktop 正常执行链路不以“先启动一个本地 HTTP server再由 Desktop 自己回连”作为目标架构
- Desktop 的 `sendMessage -> GoTaskService.executeTask -> ACP` 应理解为进程内或直接桥接语义,不是 Web server 回环语义
### Web / Mobile
- Web / Mobile UI 连接的是 Go 代码启动出来的 server
- Web / Mobile 通过标准 ACP contract 与该 server 通信
- 对 Web / Mobile 来说,`/acp` 与 `/acp/rpc` 是稳定的网络协议入口
## 协议约束
### 传输协议
@ -58,6 +72,8 @@ flowchart TD
- websocket endpoint 规范路径:`/acp`
- RPC endpoint 规范路径:`/acp/rpc`
- base URL 派生时必须避免重复拼接 `/acp`
- 以上 endpoint contract 主要适用于 Web / Mobile 与外部 ACP server 的通信语义
- Desktop 目标态不要求为自身 UI 再额外启动一层本地 HTTP ACP server
## 收敛原则
@ -70,6 +86,8 @@ flowchart TD
- 所有正常发送请求都先进入 `GoTaskService.executeTask`
- 所有任务都先进入 ACP 控制面,再解析到 executor
- Desktop 采用直接桥接 Go 代码的控制面接入方式
- Web / Mobile 采用连接 Go server 的控制面接入方式
### Compatibility route (removed from target)

View File

@ -9,8 +9,7 @@
本文是对现有 `TaskThread` 主模型的补充,不替代:
- `assistant-thread-target-model-20260328.md`
- `assistant-thread-information-architecture.md`
- `task-control-plane-unification.md`
## 1. 问题定义
@ -315,6 +314,5 @@ single-agent 入口必须在执行前验证:
推荐阅读顺序:
1. `assistant-thread-target-model-20260328.md`
2. `assistant-thread-information-architecture.md`
1. `task-control-plane-unification.md`
3. `task-thread-session-key-isolation-20260329.md`(本文)

View File

@ -1,219 +0,0 @@
# XWorkmate 集成架构
## 概述
XWorkmate 现阶段已经不只是“单一 Codex bridge”但当前实现也不是一个单独的
“Discovery / Distribution Catalog” 模块。
本文件只说明集成能力与 adapter 边界,不承担任务工作流主叙事。
任务工作流主叙事统一以
[任务执行链路统一收敛](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-task-control-plane-unification/docs/architecture/task-control-plane-unification.md)
为准。
当前集成能力分散在几条明确的实现路径里:
1. `GatewayRuntime`
- 负责 OpenClaw Gateway 的实时 RPC、会话、chat、pairing、cron
2. `MultiAgentBrokerServer` + `MultiAgentOrchestrator`
- 负责多 Agent 协作运行
3. `MultiAgentMountManager`
- 负责按 adapter 做 CLI 能力探测、MCP reconcile、挂载状态汇总
4. `CodexConfigBridge` / `OpencodeConfigBridge`
- 负责特定 CLI 的配置文件写入
5. Assistant composer 与 feature flags
- 决定当前哪些集成入口真实对用户可见
也就是说,当前架构更接近“分布式集成面”,不是单一 catalog service。
这些能力应被理解为控制面之下的 adapter / executor 能力,而不是 UI 规范直连入口。
## 当前架构基线
```mermaid
flowchart LR
X["XWorkmate App"] --> GR["GatewayRuntime"]
X --> BM["MultiAgentBrokerServer<br/>WebSocket JSON-RPC"]
X --> MM["MultiAgentMountManager"]
X --> NO["CodeAgentNodeOrchestrator"]
X --> UI["Assistant composer / Settings / Feature flags"]
BM --> O["MultiAgentOrchestrator"]
O --> C["Codex / Claude / Gemini / OpenCode"]
MM --> MA["Codex / Claude / Gemini / OpenCode / OpenClaw adapters"]
MA --> CFG["Managed config writes / mcp list / local file discovery"]
GR --> G["OpenClaw Gateway / Host"]
NO --> G
C --> A["AI Gateway or Ollama endpoint"]
```
关键点:
- `MultiAgentBroker` 是多 CLI 协作的本地运行时入口。
- `OpenClaw` 既是现有 Gateway 集成面,也是当前 app-mediated code-agent dispatch 的宿主控制面。
- `AI Gateway` 既可以是 direct AI 对话入口,也可以是协作运行的注入式模型入口。
- 当前没有一个单独命名为 `Discovery / Distribution Catalog` 的实现模块。
- `GatewayRuntime`、relay、`GatewayAcpClient` 在统一收敛目标下都应视为 adapter/executor 能力。
## 1. OpenClaw Gateway / Host
用途:
- 运行时协同
- 设备与信任边界
- Agent / Session / Chat 通道
- 宿主控制面发现
已使用能力:
- `health`
- `status`
- `agents.list`
- `sessions.list`
- `chat.send`
- `device.pair.*`
- `cron.list`
- `agent/register`
- `memory/sync`
当前定位:
- 继续作为 Gateway RPC 面存在
- 也是 app-mediated code-agent dispatch 的控制面目标
- 在 mount 视角下OpenClaw 目前更多是“本地发现 + 宿主控制面”,不是一个统一的 skills / plugins catalog service
## 2. AI Gateway
用途:
- direct AI 对话入口
- 协作运行时的模型注入入口
- 对部分 CLI 的配置桥接入口
边界:
- 不负责设备配对
- 不负责 session / agent 生命周期
- 不替换用户现有默认 provider / model
当前策略:
- `CodexConfigBridge` 可以写入受管 provider / MCP block
- `MultiAgentOrchestrator` 在协作运行中会通过环境变量或 `ollama launch` 传递模型入口
- `Claude / Gemini` 的 mount reconcile 目前主要做 discoveryAI Gateway 仍保持 launch-scoped
- `OpenCode` 当前有受管 MCP configAI Gateway 语义仍偏 launch-scoped / runtime injection
换句话说AI Gateway 能力是分散落地的,不是所有 CLI 都通过同一条托管 provider 路径接入。
## 3. Multi-Agent Runtime
### 编排层
`MultiAgentOrchestrator` 负责:
- Architect 任务分析
- Engineer 实现
- Tester / Doc 审阅
- 迭代评分与回退
### Broker 层
`MultiAgentBroker` 负责:
- 本地 `WebSocket JSON-RPC`
- run lifecycle
- worker CLI 启动
- selected skills / MCP / Gateway 上下文注入
- 结构化事件流回写当前会话
### UI 接线
- Assistant 继续复用现有 composer、附件、当前会话
- 桌面端真正对用户可见的协作入口,当前主要是 Assistant composer 上的协作 toggle
- `SettingsPage` 里有 Multi-Agent 配置区块与 detail 页面代码,但桌面端 `settings.agents` 仍被 feature flag 关闭
- 不新增独立任务页面
## 4. 发现与分发
当前实现里,`managed / external` 更像一套按 adapter 执行的操作规则,而不是单独的中心化状态目录。
XWorkmate 仍然区分两类对象:
- `managed`
- 由 App 创建与维护的托管项
- `external`
- 外部已有配置或 CLI 自带配置
统一规则:
- 只更新 XWorkmate 托管项
- 不删除外部已有项
- 启动时与保存设置后自动 reconcile
- 这套规则当前由 `MultiAgentMountManager` 在各 adapter 上分别执行
## 5. 挂载入口矩阵
| 目标 | Skills 挂载入口 | MCP 挂载入口 | AI Gateway 挂载入口 |
| --- | --- | --- | --- |
| OpenClaw | 本地文件 / 目录发现 + Gateway 控制面 | 不作为 MCP 主挂载点 | app-mediated dispatch / gateway route |
| Codex | 当前线程 skills 上下文 +协作运行注入 | `~/.codex/config.toml` 受管 MCP block | 受管 provider bridge + runtime injection |
| Claude | 当前线程 skills 上下文 +协作运行注入 | `claude mcp list` 做 discovery | launch-scoped / env / `ollama launch` |
| Gemini | 当前线程 skills 上下文 +协作运行注入 | `gemini mcp list` 做 discovery | launch-scoped / env |
| OpenCode | 当前线程 skills 上下文 +协作运行注入 | `~/.opencode/config.toml` 受管 MCP block | runtime injection |
## 6. 外部 Provider 与执行路径
保留现有统一 contract
- `ExternalCodeAgentProvider.id`
- `name`
- `command`
- `defaultArgs`
- `capabilities`
- `CodeAgentNodeOrchestrator.buildGatewayDispatch()`
现状:
- `codex` 仍是当前最完整 provider
- 其他 CLI 当前主要通过 `CliMountAdapter` discovery / reconcile 与 `MultiAgentOrchestrator` 运行时调用接入
- 多 provider 调度 UI 不是当前交付目标
## 7. 安全边界
- `.env` 仅用于开发预填充,不自动连接,不作为持久化真值源
- AI Gateway API Key 与 Gateway 凭证继续走 secure storage
- 新增协作路径不得把 secret 写入 `SharedPreferences`
- Launch-scoped 注入优先于全局配置改写
- 远程 Gateway 不允许静默降级为非 TLS
- 协作事件与 metadata 不上传本地 secret 或本机绝对路径
## 8. 设置页统一动作语义Gateway 家族)
`OpenClaw Gateway`、`Vault`、`AI Gateway`(以及后续外部扩展)统一遵循同一操作语义:
- `Test`:只使用当前草稿(含当前输入的临时 secret 覆盖)做连通性校验,不写入持久层。
- `Save`:把草稿同步到本地持久存储(`SettingsStore` + `SecretStore`),不立即改变运行时会话行为。
- `Apply`:在 `Save` 的基础上,立即让当前运行时按新配置生效。
实现约束:
- Gateway 集成页不再重复显示顶层全局 `Save / Apply`,避免与卡片内动作语义冲突。
- 桌面端 `settings.gateway_setup_code``settings.agents` 当前都被 feature flag 关闭。
- 但桌面端 `assistant.multi_agent` 仍然开启,所以协作入口当前主要暴露在 Assistant composer而不是设置页独立标签。
## 相关代码
- `lib/app/app_controller_desktop.dart`
- `lib/app/app_controller_web.dart`
- `lib/features/assistant/assistant_page.dart`
- `lib/features/settings/settings_page.dart`
- `lib/runtime/gateway_runtime.dart`
- `lib/runtime/runtime_models.dart`
- `lib/runtime/multi_agent_orchestrator.dart`
- `lib/runtime/multi_agent_broker.dart`
- `lib/runtime/multi_agent_mounts.dart`
- `lib/runtime/codex_config_bridge.dart`
- `lib/runtime/opencode_config_bridge.dart`
- `lib/runtime/code_agent_node_orchestrator.dart`
- `lib/runtime/runtime_coordinator.dart`

View File

@ -1,96 +0,0 @@
# XWorkmate App Internal State Architecture
Last Updated: 2026-04-08
## Purpose
本文定义当前 XWorkmate 的内部状态组织,重点说明以下对象之间的关系:
- Settings 中心配置状态
- `TaskThread` 线程状态
- `GoTaskService / runtime` 协调状态
- 派生 UI 状态
目标规范以
[任务执行链路统一收敛](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-task-control-plane-unification/docs/architecture/task-control-plane-unification.md)
为准。
## Core Rule
当前内部状态仍分为:
- Layer A: Settings 中心配置状态
- Layer B: `TaskThread` 线程状态
- Layer C: 派生 UI 状态
最重要的规则是:
- Settings 不是当前线程状态
- `TaskThread` 负责当前线程真实使用的工作空间、执行通道、上下文和生命周期
- UI 必须从解析后的 `TaskThread` 渲染
## Internal State Diagram
```mermaid
graph TB
subgraph P["Persistence Layer"]
SettingsStore["SettingsStore"]
SecretStore["SecretStore"]
SecureConfigStore["SecureConfigStore"]
ThreadRepository["TaskThread Repository"]
end
subgraph C["Controllers"]
settings["settings / settingsDraft"]
threadRecords["thread records<TaskThread>"]
currentThreadId["current thread id"]
runtimeCaches["streaming / preview caches"]
end
subgraph R["GoTaskService / Runtime Coordination"]
threadReader["read TaskThread by threadId"]
requestBuilder["build execution request"]
dispatcher["GoTaskService.executeTask"]
acp["ACP Control Plane"]
executors["resolved executors"]
resultWriter["write result back to TaskThread"]
end
subgraph U["Derived UI State"]
assistantPage["AssistantPage"]
sidebar["right sidebar / preview"]
taskList["task list"]
settingsPage["SettingsPage"]
end
SettingsStore --> settings
SecureConfigStore --> settings
SecretStore --> settings
ThreadRepository --> threadRecords
currentThreadId --> threadReader
threadRecords --> threadReader
threadReader --> requestBuilder
requestBuilder --> dispatcher
dispatcher --> acp
acp --> executors
executors --> resultWriter
resultWriter --> threadRecords
threadReader --> assistantPage
threadReader --> sidebar
threadRecords --> taskList
settings --> settingsPage
```
## Current implementation note
- controller 侧可能仍有旧 dispatch 痕迹
- 当前目标是让这些痕迹退出长期状态架构口径
## Target architecture rule
- `GoTaskService.executeTask` 是唯一公开任务入口
- ACP 是统一控制面
- `gateway` 是解析出的 executor不再作为 UI 规范旁路
- runtime cache 只承载瞬时状态,不承载线程长期语义

View File

@ -97,13 +97,12 @@ flowchart TB
### 当前实现观察
- [Assistant TaskThread 当前模型2026-03-28](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-task-control-plane-unification/docs/architecture/assistant-thread-target-model-20260328.md)
- [Assistant TaskThread 信息架构](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-task-control-plane-unification/docs/architecture/assistant-thread-information-architecture.md)
- [XWorkmate App Internal State Architecture](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-task-control-plane-unification/docs/architecture/xworkmate-internal-state-architecture.md)
- 当前实现观察不再保留独立主设计文档
- 如需判断规范,以 [任务执行链路统一收敛](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-task-control-plane-unification/docs/architecture/task-control-plane-unification.md) 为准
### 边界与适配器说明
- [XWorkmate 集成架构](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-task-control-plane-unification/docs/architecture/xworkmate-integrations.md)
- 适配器边界统一收敛到本文件与主文档,不再保留旧的并列设计稿
## Compatibility route (removed from target)

View File

@ -20,7 +20,7 @@ XWorkmate 的 iOS/Android 不应该被定义成“第二个 Code Agent 运行端
- 仓库现状:
- XWorkmate 当前仍是 desktop-first。
- 移动端已存在统一壳层,但本质是 UI surface不是独立执行 runtime。
- 参考:[README.md](../../README.md)、[xworkmate-integrations.md](../architecture/xworkmate-integrations.md)、[mobile_shell.dart](../../lib/features/mobile/mobile_shell.dart)
- 参考:[README.md](../../README.md)、[task-control-plane-unification.md](../architecture/task-control-plane-unification.md)、[mobile_shell.dart](../../lib/features/mobile/mobile_shell.dart)
- Codex 用户诉求:
- 2025-08-27 的 `#2798` 请求 remote/headless sign-in。
- 2025-09-02 的 `#3052` 请求 approval/job completion 通知。
@ -347,7 +347,7 @@ Mac Node Agent
### 本地架构与现状
- [README.md](../../README.md)
- [xworkmate-integrations.md](../architecture/xworkmate-integrations.md)
- [task-control-plane-unification.md](../architecture/task-control-plane-unification.md)
- [gateway-dev-runbook.md](../runbooks/gateway-dev-runbook.md)
- [mobile_shell.dart](../../lib/features/mobile/mobile_shell.dart)
- [app_controller.dart](../../lib/app/app_controller.dart)

View File

@ -691,39 +691,14 @@ extension AppControllerWebHelpers on AppController {
Future<Map<String, dynamic>> requestAcpSessionMessageInternal({
required Uri endpoint,
required Map<String, dynamic> params,
required bool hasInlineAttachments,
void Function(Map<String, dynamic> notification)? onNotification,
}) async {
try {
return await acpClientInternal.request(
endpoint: endpoint,
method: 'session.message',
params: params,
onNotification: onNotification,
);
} on WebAcpException catch (error) {
if (!hasInlineAttachments ||
!canFallbackInlineAttachmentsInternal(error)) {
rethrow;
}
final fallbackParams = Map<String, dynamic>.from(params)
..remove('inlineAttachments');
try {
return await acpClientInternal.request(
endpoint: endpoint,
method: 'session.message',
params: fallbackParams,
onNotification: onNotification,
);
} on Object catch (fallbackError) {
throw Exception(
appText(
'ACP 暂不支持 inline 附件,回退旧协议也失败:$fallbackError',
'ACP does not support inline attachments, and fallback to legacy attachment payload failed: $fallbackError',
),
);
}
}
return acpClientInternal.request(
endpoint: endpoint,
method: 'session.message',
params: params,
onNotification: onNotification,
);
}
Future<void> refreshAcpCapabilitiesInternal(Uri endpoint) async {
@ -736,18 +711,6 @@ extension AppControllerWebHelpers on AppController {
}
}
bool canFallbackInlineAttachmentsInternal(WebAcpException error) {
final code = (error.code ?? '').trim();
if (code == '-32602' || code == 'INVALID_PARAMS') {
return true;
}
final message = error.toString().toLowerCase();
return message.contains('inlineattachment') ||
message.contains('unexpected field') ||
message.contains('unknown field') ||
message.contains('invalid params');
}
bool unsupportedAcpSkillsStatusInternal(WebAcpException error) {
final code = (error.code ?? '').trim();
if (code == '-32601' || code == 'METHOD_NOT_FOUND') {

View File

@ -309,58 +309,8 @@ class GatewayAcpClient {
}) async {
final resolvedEndpoint = endpointOverride ?? endpointResolver();
final scheme = resolvedEndpoint?.scheme.trim().toLowerCase() ?? '';
final canUseHttp = resolveAcpHttpRpcEndpoint(resolvedEndpoint) != null;
if (scheme == 'http' || scheme == 'https') {
try {
return await _requestViaHttp(
request,
onNotification: onNotification,
endpointOverride: resolvedEndpoint,
authorizationOverride: authorizationOverride,
);
} catch (error) {
if (error is GatewayAcpException) {
if (!_shouldRetryViaWebSocketAfterHttpFailure(
error,
endpoint: resolvedEndpoint,
)) {
rethrow;
}
try {
return await _requestViaWebSocket(
request,
onNotification: onNotification,
endpointOverride: resolvedEndpoint,
authorizationOverride: authorizationOverride,
);
} catch (wsError) {
throw _toPreferredHttpFailureAfterWebSocketFallback(
httpError: error,
webSocketError: wsError,
);
}
}
return _requestViaWebSocket(
request,
onNotification: onNotification,
endpointOverride: resolvedEndpoint,
authorizationOverride: authorizationOverride,
);
}
}
try {
return await _requestViaWebSocket(
request,
onNotification: onNotification,
endpointOverride: resolvedEndpoint,
authorizationOverride: authorizationOverride,
);
} catch (_) {
if (!canUseHttp) {
rethrow;
}
return _requestViaHttp(
request,
onNotification: onNotification,
@ -368,6 +318,13 @@ class GatewayAcpClient {
authorizationOverride: authorizationOverride,
);
}
return _requestViaWebSocket(
request,
onNotification: onNotification,
endpointOverride: resolvedEndpoint,
authorizationOverride: authorizationOverride,
);
}
Future<Map<String, dynamic>> _requestViaWebSocket(
@ -376,35 +333,20 @@ class GatewayAcpClient {
Uri? endpointOverride,
String authorizationOverride = '',
}) async {
final endpoints = _resolveWebSocketRpcEndpoints(endpointOverride);
if (endpoints.isEmpty) {
final endpoint = resolveAcpWebSocketEndpoint(
endpointOverride ?? endpointResolver(),
);
if (endpoint == null) {
throw const GatewayAcpException(
'Missing ACP endpoint',
code: 'ACP_ENDPOINT_MISSING',
);
}
Object? lastError;
for (var index = 0; index < endpoints.length; index += 1) {
final endpoint = endpoints[index];
try {
return await _requestViaWebSocketEndpoint(
request,
endpoint: endpoint,
onNotification: onNotification,
authorizationOverride: authorizationOverride,
);
} catch (error) {
lastError = error;
if (index == endpoints.length - 1 ||
!_shouldTryNextWebSocketCandidate(error)) {
rethrow;
}
}
}
throw GatewayAcpException(
lastError?.toString() ?? 'ACP websocket request failed',
code: 'ACP_WS_RUNTIME_ERROR',
return _requestViaWebSocketEndpoint(
request,
endpoint: endpoint,
onNotification: onNotification,
authorizationOverride: authorizationOverride,
);
}
@ -592,54 +534,6 @@ class GatewayAcpClient {
return base;
}
bool _shouldRetryViaWebSocketAfterHttpFailure(
GatewayAcpException error, {
required Uri? endpoint,
}) {
if (resolveAcpWebSocketEndpoint(endpoint) == null) {
return false;
}
final details = asMap(error.details);
final statusCode = intValue(details['statusCode']);
final contentType = (stringValue(details['contentType']) ?? '')
.trim()
.toLowerCase();
return statusCode == HttpStatus.notFound &&
(contentType.isEmpty || contentType.contains('text/plain'));
}
bool _shouldTryNextWebSocketCandidate(Object error) {
if (error is WebSocketException ||
error is SocketException ||
error is HandshakeException ||
error is HttpException) {
return true;
}
if (error is! GatewayAcpException) {
return false;
}
return error.code == 'ACP_WS_EARLY_CLOSE' ||
error.code == 'ACP_WS_RUNTIME_ERROR' ||
error.code == 'ACP_WS_CONNECT_TIMEOUT';
}
GatewayAcpException _toPreferredHttpFailureAfterWebSocketFallback({
required GatewayAcpException httpError,
required Object webSocketError,
}) {
final wsError = webSocketError is GatewayAcpException
? webSocketError
: null;
return GatewayAcpException(
httpError.message,
code: httpError.code,
details: <String, dynamic>{
...asMap(httpError.details),
if (wsError?.code != null) 'websocketFallbackCode': wsError!.code,
},
);
}
bool _contentTypeLooksJsonOrSse(String contentType) {
return contentType.contains('application/json') ||
contentType.contains('application/problem+json') ||
@ -879,30 +773,6 @@ class GatewayAcpClient {
return const <String, dynamic>{};
}
List<Uri> _resolveWebSocketRpcEndpoints([Uri? endpointOverride]) {
final endpoint = endpointOverride ?? endpointResolver();
if (endpoint == null || endpoint.host.trim().isEmpty) {
return const <Uri>[];
}
final candidates = <Uri>[];
final derived = resolveAcpWebSocketEndpoint(endpoint);
if (derived != null) {
candidates.add(derived);
}
final scheme = switch (endpoint.scheme.trim().toLowerCase()) {
'https' || 'wss' => 'wss',
_ => 'ws',
};
final raw = endpoint.replace(scheme: scheme, query: null, fragment: null);
final duplicate = candidates.any(
(candidate) => candidate.toString() == raw.toString(),
);
if (!duplicate) {
candidates.add(raw);
}
return candidates;
}
Uri? _resolveHttpRpcEndpoint([Uri? endpointOverride]) {
return resolveAcpHttpRpcEndpoint(endpointOverride ?? endpointResolver());
}

View File

@ -121,7 +121,7 @@ void main() {
);
test(
'falls back to websocket when HTTP bridge returns plain-text 404',
'keeps HTTP 404 as the final error when HTTP ACP bridge is missing',
() async {
final server = await _AcpFakeServer.start(
respondWithPlainTextNotFound: true,
@ -132,28 +132,6 @@ void main() {
endpointResolver: () => server.baseHttpUri,
);
final capabilities = await client.loadCapabilities(forceRefresh: true);
expect(capabilities.singleAgent, isTrue);
expect(server.lastHttpRequestPath, '/acp/rpc');
expect(server.lastWebSocketRequestPath, '/acp');
expect(server.rpcMethods, contains('acp.capabilities'));
},
);
test(
'keeps HTTP 404 as primary error when websocket fallback also fails',
() async {
final server = await _AcpFakeServer.start(
disableWebSocket: true,
respondWithPlainTextNotFound: true,
);
addTearDown(server.close);
final client = GatewayAcpClient(
endpointResolver: () => server.baseHttpUri,
);
await expectLater(
() => client.loadCapabilities(forceRefresh: true),
throwsA(
@ -166,29 +144,7 @@ void main() {
),
),
);
},
);
test(
'falls back to raw websocket path when derived ACP path is unavailable',
() async {
final server = await _AcpFakeServer.start(
respondWithPlainTextNotFound: true,
pathPrefix: '/opencode',
useRawWebSocketPathOnly: true,
);
addTearDown(server.close);
final client = GatewayAcpClient(
endpointResolver: () => server.baseHttpUri,
);
final capabilities = await client.loadCapabilities(forceRefresh: true);
expect(capabilities.singleAgent, isTrue);
expect(server.lastHttpRequestPath, '/opencode/acp/rpc');
expect(server.lastWebSocketRequestPath, '/opencode');
expect(server.rpcMethods, contains('acp.capabilities'));
expect(server.lastWebSocketRequestPath, isNull);
},
);
@ -310,7 +266,6 @@ class _AcpFakeServer {
required this.disableWebSocket,
required this.respondWithHtmlError,
required this.respondWithPlainTextNotFound,
required this.useRawWebSocketPathOnly,
required this.pathPrefix,
});
@ -318,7 +273,6 @@ class _AcpFakeServer {
final bool disableWebSocket;
final bool respondWithHtmlError;
final bool respondWithPlainTextNotFound;
final bool useRawWebSocketPathOnly;
final String pathPrefix;
final List<String> rpcMethods = <String>[];
String? lastWebSocketAuthorization;
@ -333,7 +287,6 @@ class _AcpFakeServer {
bool disableWebSocket = false,
bool respondWithHtmlError = false,
bool respondWithPlainTextNotFound = false,
bool useRawWebSocketPathOnly = false,
String pathPrefix = '',
}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
@ -342,7 +295,6 @@ class _AcpFakeServer {
disableWebSocket: disableWebSocket,
respondWithHtmlError: respondWithHtmlError,
respondWithPlainTextNotFound: respondWithPlainTextNotFound,
useRawWebSocketPathOnly: useRawWebSocketPathOnly,
pathPrefix: _normalizePathPrefix(pathPrefix),
);
unawaited(fake._listen());
@ -355,12 +307,8 @@ class _AcpFakeServer {
Future<void> _listen() async {
await for (final request in _server) {
final wsPaths = <String>[
if (!useRawWebSocketPathOnly) '$pathPrefix/acp',
if (useRawWebSocketPathOnly) (pathPrefix.isEmpty ? '/' : pathPrefix),
];
if (!disableWebSocket &&
wsPaths.contains(request.uri.path) &&
request.uri.path == '$pathPrefix/acp' &&
WebSocketTransformer.isUpgradeRequest(request)) {
lastWebSocketRequestPath = request.uri.path;
lastWebSocketAuthorization = request.headers.value(