Skip to content

Feat/acp runtime abstraction#2

Open
yhsunshining wants to merge 30 commits intomainfrom
feat/acp-runtime-abstraction
Open

Feat/acp runtime abstraction#2
yhsunshining wants to merge 30 commits intomainfrom
feat/acp-runtime-abstraction

Conversation

@yhsunshining
Copy link
Copy Markdown
Member

No description provided.

把 server 端 agent 实现与会话/流式/持久化基础设施解耦,为接入多种
开源 agent 铺路。

## 新增

- packages/server/src/agent/runtime/
    - types.ts           IAgentRuntime 接口定义
    - registry.ts        AgentRuntimeRegistry(注册 + 选择策略)
    - tencent-sdk-runtime.ts  现有 cloudbaseAgentService 的薄 adapter
    - opencode-acp-runtime.ts spawn `opencode acp`,走 ACP NDJSON
    - index.ts           公共导出

- packages/server/scripts/
    - test-opencode-runtime.mts  e2e 直调 runtime
    - test-acp-http-e2e.mts      e2e 公开端点
    - test-acp-chat-http.mts     e2e 完整 SSE

- docs/acp-runtime-abstraction.md 架构与交付文档

## 改动

- routes/acp.ts: /chat 与 session/prompt 改走 runtime,新增 GET /runtimes
- shared/types/agent.ts: SessionPromptParams 加 runtime? 字段
- server/package.json: +@agentclientprotocol/sdk@^0.21.0

## 行为约定

- IAgentRuntime.chatStream 同步返回 {turnId, alreadyRunning},后台 fire-and-forget
- 所有 runtime 统一把内部事件翻译为 AgentCallbackMessage,
  由 CloudbaseAgentService.convertToSessionUpdate 转为 ACP SessionUpdate
- registry 选择优先级:
  请求 body.runtime > AGENT_RUNTIME env > AGENT_RUNTIME_DEFAULT > 'tencent-sdk'

## 验证

type-check / lint / build / 3 组 e2e 全通过。
完整 SSE 链路验证:/chat runtime=opencode-acp → Moonshot Kimi
真实响应、21 个 SSE events、文件写入正确。

## 已知限制(首版)

OpencodeAcpRuntime 是最小可用:
- 未实现 askAnswers / toolConfirmation resume
- 权限请求默认 allow_once(未接 ToolConfirm UI)
- 工具执行在 opencode 进程内(非 SCF 沙箱)
- 仅 stream events 落库,不同步 messages 表

详见 docs/acp-runtime-abstraction.md §8。
严格遵守 agent/sandbox 分离原则实现 OpenCode runtime 的沙箱集成:
- OpenCode 进程保留在 server 本地(agent 域)
- 所有文件/shell 工具调用通过 MCP bridge 转发到 SCF 沙箱(sandbox 域)
- OpenCode 内置 read/write/bash/edit 工具**被禁用**,LLM 只能用 sbx_* 工具

## 架构

```
opencode acp (server 本地)
  │
  ├─ 内置 read/write/bash/edit (已禁用)
  │
  └─ MCP client ─stdio─► sandbox-mcp-bridge.js (子进程)
                             │
                             └─ HTTP ─► SCF /api/tools/{read,write,bash,...}
                                          → 沙箱隔离目录 /tmp/workspace/<env>/<conv>
```

## 实现

### 新增
- src/agent/runtime/sandbox-mcp-bridge.ts
  独立进程,MCP stdio server + 沙箱 HTTP 桥接。
  暴露 read/write/edit/bash/glob/grep 6 个工具。
  沙箱凭证通过 env 注入。
  tsup 单独 build 成 dist/agent/runtime/sandbox-mcp-bridge.js。
- src/agent/runtime/opencode-session-config.ts
  per-session 临时工作目录 + 生成 opencode.json:
  - agent.sandboxed.tools = { read:false,…, sbx_*:true }
  - mcp.sbx = { command: [node, bridge.js], env: {…} }
  - AGENTS.md 明确告诉 LLM 用 sbx_* 相对路径
- src/agent/runtime/acp-transport.ts
  stdio transport 工厂抽象(当前只有 local-stdio)
- scripts/test-opencode-sandbox-e2e.mts
  完整沙箱隔离 e2e:
  让 LLM 用 sbx_write 写文件 → 独立调沙箱 read 验证 → 确认本地未被污染

### 改动
- src/agent/runtime/opencode-acp-runtime.ts
  引入 sandbox-aware 启动流程:getOrCreate sandbox → 生成 per-session config →
  spawn opencode → setSessionMode('sandboxed') → prompt
- package.json
  + @modelcontextprotocol/sdk
  build 脚本加 sandbox-mcp-bridge.ts(ESM 单文件)

## 关键发现(已处理)

1. OpenCode 的 ACP agent 不发 fs/* / terminal/* client 回调(所有内置工具本地执行)
   → 所以 ACP client 回调方向不可行,必须用 MCP 注入
2. OpenCode 支持 per-agent `tools: { name: boolean }` 配置禁用内置工具
3. OpenCode MCP 工具名会自动加 <server>_ 前缀(server='sbx' → sbx_read/sbx_write)
4. 当前沙箱 /api/tools/{read,write,...} 拒绝所有绝对路径(403 Path traversal blocked)
   只接受相对路径 → AGENTS.md 已注明

## 验证结果

### e2e#4 沙箱隔离(真实 SCF 环境)
```
[tool_use ▶] name=sbx_write
[tool_result ◯] out="Wrote file successfully."

sandbox file content = "1: hello from sandbox i24jd6en"   ← 沙箱里真的写入
local /tmp/hello-sandbox-...txt exists = false            ← 本地未被污染

  used sbx_* tools:            PASS (count=1)
  did NOT use builtin tools:   PASS (count=0)
  sandbox file has expected:   PASS
  local NOT polluted:          PASS
OVERALL: PASS
```

### 回归
- e2e#1 (本地模式) PASS — 无 envId 时自动退回,不禁用内置工具
- e2e#2 (HTTP 公开端点) PASS
- e2e#3 (HTTP SSE chat) PASS
- type-check / lint / build / format 全通过

## 首版限制

- ToolConfirm UI 回调未接(权限默认 allow_once)
- askAnswers / toolConfirmation resume 未实现
- coding-mode 模板初始化未集成
- 消息持久化只落 stream events

详见 docs/acp-runtime-abstraction.md §9、§12。
替代原方案(per-session .opencode/opencode.json + sandbox MCP bridge 子进程)。
新方案更干净、更符合 OpenCode 原生扩展机制。

## 关键发现(实测验证)

1. OpenCode 扫描 `~/.config/opencode/tools/*.ts` 作为 custom tools,
   **同名 custom tool 覆盖 builtin**(文档承诺 + 日志观察均已证实)。
2. OpenCode 原生支持 .ts 文件(zod 作为它自己的依赖已打包)。
   但 tsup 编译后的 .js 文件保留 `import 'zod'` 静态语句,当前 opencode
   binary 无法解析这种外部 import,会静默忽略。所以 installer 直接拷 .ts 源文件。
3. OpenCode 的 MCP / tool 子进程继承父 opencode 进程的 process.env
   (源码 packages/opencode/src/mcp/index.ts: `env: { ...process.env, ... }`)。
   → 我们 spawn opencode 时注入的 env 能传到工具执行处。

## 改动

### 新增
- src/agent/runtime/opencode-installer.ts
  幂等安装 6 个工具模板到 ~/.config/opencode/tools/。
  hash 比对跳过未变更;OPENCODE_TOOLS_FORCE_REINSTALL=1 强制覆盖。
- src/agent/runtime/opencode-tool-templates/
  {read, write, edit, bash, grep, glob}.ts — 每个文件独立自包含:
  if (SANDBOX_MODE === '1') → fetch SANDBOX_BASE_URL/api/tools/*
  else                     → 本地 fs / child_process

### 重写
- src/agent/runtime/opencode-acp-runtime.ts
  每次 chatStream:
    1. ensureToolsInstalledOnce() — 惰性安装
    2. scfSandboxManager.getOrCreate(convId, envId)
    3. spawn opencode acp, env={
         SANDBOX_MODE:'1',
         SANDBOX_BASE_URL, SANDBOX_AUTH_HEADERS_JSON,
       }
    4. ACP 握手 → newSession → prompt
  删除 setSessionMode / mcpServers / 临时目录写 opencode.json 等旧逻辑

### 删除
- sandbox-mcp-bridge.ts(MCP 子进程中转层)
- opencode-session-config.ts(per-session 配置写入)

## 测试结果

### e2e#1 本地模式
```
[tool_result ◯] out="Wrote 18 bytes to /tmp/opencode-runtime-e2e/hello.txt"
                      ^^^^^^^^^^^^^ 这是 custom write.ts 的输出格式
OVERALL: PASS
```

### e2e#4 沙箱隔离(真实 SCF)
```
[tool_use ▶] name=write           ← LLM 用 write 工具(不是 sbx_write)
[tool_result] out="{path: /tmp/workspace/huming-test-.../sandbox-e2e-v2-.../...txt,
                   bytesWritten: 22, diff: ...}"   ← 沙箱 HTTP API 响应格式

sandbox file content = "1: hello from v2 vusknorg"  ← 独立调沙箱 read 验证
local /tmp/.../txt exists = false                    ← 本地干净

  LLM used 'write' tool:      PASS
  sandbox file has content:   PASS
  local NOT polluted:         PASS
OVERALL: PASS
```

### 其他回归
- e2e#2 HTTP /runtimes: PASS
- e2e#3 HTTP SSE chat: PASS (28 events)
- type-check / lint / build / format 全通过

## 优势对比(相比 MCP bridge 方案)

- 工具名 `read/write/bash/edit` 与 LLM 训练期望一致(不用 sbx_ 前缀)
- 少一个 MCP 子进程 + 一层 JSON-RPC 序列化
- 凭证只停留在进程 env 里,session 结束即消失(无磁盘残留)
- per-session 开销 = spawn opencode(不多写任何文件)
ACP requestPermission 不再自动 allow_once。现在:
  1. 发 AgentCallbackMessage(type='tool_confirm') 给前端
  2. 注册 pending Promise,agent handler 挂起
  3. 用户下一轮 prompt 带 toolConfirmation 到达时 resolve Promise
  4. opencode 收到 outcome 继续执行

前端零改动(原为 Tencent runtime 设计的 ToolConfirmDialog 直接复用)。

## 新增

- src/agent/runtime/pending-permission-registry.ts
  挂起 ACP 权限请求的 in-memory registry,key=(convId, interruptId)。
  registerPending → await Promise;resolvePending → resolve。
  PermissionAction(前端) → ACP optionKind 映射
  (allow→allow_once, deny→reject_once, 等)。

- scripts/test-tool-confirm-e2e.mts
  完整 e2e:
    Round 1 不带 toolConfirmation → 等 tool_confirm 事件
    → 确认挂起 3 秒无 result(证明真 suspend)
    Round 2 带 toolConfirmation(allow) → alreadyRunning=true
    → 等 tool_result + result → 验证文件真的写入

## 改动

- src/agent/runtime/opencode-acp-runtime.ts
  - chatStream 入口:若 isAgentRunning + options.toolConfirmation
    → resolvePending,不 spawn 新进程
  - liveCallbacks map:跨 SSE 流动态切换(resume 的 callback 是新 HTTP 请求的)
  - requestPermission handler:emit tool_confirm + await registerPending
  - 错误/abort:rejectPendingForConversation 避免 opencode 子进程卡死
  - 加 OPENCODE_SKIP_TOOLS_INSTALL=1 env(测试场景跳过 tool 安装)

## 测试结果

e2e#5 test-tool-confirm-e2e.mts:
```
[+11197ms] tool_confirm id=write:0 name=edit input={filepath:...hello.txt, diff:...}
[+11197→14197ms] [agent suspended, no result]         ← 关键:挂起验证
[+14304ms] tool_result id=write:0 is_error=false    ← resume 后恢复
[+15885ms] result: {stopReason: end_turn}

  tool_confirm received:        PASS
  agent suspended after it:     PASS (no 'result' for 3s)
  round2 took resume path:      PASS (alreadyRunning=true)
  tool_result after resume:     PASS
  final result event:           PASS
  no error event:               PASS
  file has expected content:    PASS
OVERALL: PASS
```

其他回归:e2e#1/#2/#3 本地 PASS;type-check/lint/build/format 全通过。

## 已知边界

- 触发 requestPermission 依赖 opencode.json permission 配置(edit/bash/webfetch = 'ask')
- custom tool override 走独立代码路径,**不触发** permission(沙箱场景本就不需要额外拦截)
- 目前无 pending 超时机制,用户不回应会让 opencode 子进程一直占用
OpenCode 原生 ACP 不支持 AskUserQuestion(内置 question tool 在 ACP 模式默认
禁用,且 ACP agent 层没订阅 Question 事件总线,会死锁)。我们自己实现:
通过 custom tool override + server 本地 HTTP endpoint 挂起机制完成。

## 新增

- src/agent/runtime/pending-question-registry.ts
  独立的挂起 HTTP response registry(对比 permission registry 挂 JSON-RPC
  Promise)。registerPendingQuestion / resolvePendingQuestion /
  rejectPendingQuestionsForConversation。

- src/agent/runtime/opencode-tool-templates/question.ts
  custom tool,参数 schema 与 opencode 原生一致(header/question/options/multiple)。
  execute 里 fetch ASK_USER_URL(env 注入),阻塞等 HTTP 响应里的 answers,
  返回格式化文本给 LLM。

- scripts/test-ask-user-e2e.mts
  完整 e2e:起 mini Hono app 挂 acp 路由 + 127.0.0.1 + 随机端口;
  Round 1 让 LLM 调 question tool → 验证 ask_user 事件 + 3s 挂起无 result;
  Round 2 带 askAnswers → 验证 alreadyRunning=true + tool_result 含答案 +
  LLM 文本引用答案。

## 改动

- routes/acp.ts
  - 新 POST /api/agent/internal/ask-user(仅 127.0.0.1 + X-Internal-Token 认证)
  - middleware 对 /internal/* 豁免 requireUserEnv,走 token 认证
  - handler 注册 PendingQuestion + emitForConversation 推 ask_user 给前端 SSE
  - 10 分钟超时返 408

- src/agent/runtime/opencode-acp-runtime.ts
  - 新 emitters map + emitForConversation() 导出
  - getAskUserToken() 惰性生成 16-byte hex 共享 token
  - spawn opencode 时注入 ASK_USER_URL / ASK_USER_TOKEN / ASK_USER_CONVERSATION_ID
  - chatStream resume 分支处理 options.askAnswers → resolvePendingQuestion
  - 错误/abort 路径一并 rejectPendingQuestionsForConversation

- src/agent/runtime/opencode-installer.ts
  TOOL_NAMES 加 'question'

- src/index.ts
  server 启动时设置 ASK_USER_BASE_URL env(127.0.0.1:<port>)

- shared/types/agent.ts 无改动:askAnswers / toolConfirmation 定义早就存在(P2
  设计时为 Tencent SDK 用的)

## 测试结果

e2e (AskUserQuestion):
```
Round 1:
  +11670ms  tool_use name=question
  +15092ms  ★ ask_user questions=[{header:Database, options:[PostgreSQL, MySQL]}]
  [agent 挂起 3s 无 result]
Round 2 (askAnswers PostgreSQL):
  alreadyRunning=true
  +18149ms  tool_result: "User answered: Which database to use? → PostgreSQL..."
  +36349ms  result: end_turn
  LLM 文本引用答案: "Great! You've chosen PostgreSQL..."

  ask_user received:             PASS
  agent suspended after:         PASS
  round2 resume path:            PASS
  tool_result with user answer:  PASS
  final result event:            PASS
  no error event:                PASS
  text mentions answer:          PASS (bonus)
OVERALL: PASS
```

回归:e2e#1 local + ToolConfirm e2e 全 PASS;type-check/lint/build/format 通过。

## 已知边界

- LLM 主动调用 question 工具依赖 prompt 明确指示(Kimi 默认倾向自己拍板)
- 10 分钟超时(ASK_USER_TIMEOUT_MS env)
- Token 静态生成,进程重启会变(生产建议 env 显式注入)
前端 task-chat.tsx 按 part.toolName === 'AskUserQuestion' 匹配渲染 AskUserForm,
schema 字段(multiSelect / option.description 必填)也与 Tencent 生态锁死。
原实现用 'question' / 'multiple',前端看不到——特殊 UI 分叉。本次全面对齐。

## 改动

- 重命名 question.ts → AskUserQuestion.ts
  (OpenCode 约定:文件名=工具名,PascalCase 实测支持;tool_call.title 直接是文件名)

- schema 对齐 packages/web/src/types/task-chat.ts:AskUserQuestionData:
  - `multiple: boolean` → `multiSelect: boolean`(camelCase)
  - `option.description` 从 optional 改为必填(前端渲染需要)
  - 加 min(2).max(4) 约束 options 数量(对齐 Tencent 习惯)
  - question header 限 max(30)(对齐前端 Badge 显示宽度)

- installer TOOL_NAMES 'question' → 'AskUserQuestion'

- e2e 新增 4 条断言:
  tool_use name='AskUserQuestion' + questions[0].header/question +
  options[].label/description + options length >= 2

## 实测证明

PoC test-uppercase-tool.mjs 确认:
- 文件名 `AskUserQuestion.ts` → opencode 注册 tool id `AskUserQuestion`
- tool_call 事件 title: 'AskUserQuestion'(直接进 SessionUpdate.title → 前端 part.toolName)

e2e OVERALL: PASS (11/11 断言)
  tool_use name='AskUserQuestion': PASS
  questions[0].header/question:    PASS
  options[].label+description:     PASS
  options length >= 2:             PASS
  ask_user received:               PASS
  agent suspended after:           PASS
  round2 resume path:              PASS
  tool_result with user answer:    PASS
  final result event:              PASS
  no error event:                  PASS
  text mentions answer:            PASS (bonus)

回归:type-check/lint/build/format 全通过。本地 e2e 重试 PASS(首次 LLM 非
确定性 text=0 与本次改动无关)。
投产硬门槛:让 OpenCode 会话历史与 Tencent 一致地落 vibe_agent_messages 集合,
前端 /api/tasks/:id/messages 读回后能正常渲染完整对话(下次打开仍可见)。

## 架构(三时间点对齐 Tencent)

    chatStream(prompt)
        ├─ findLastRecordIds → 建 replyTo 链
        ├─ preSavePendingRecords → user(done) + assistant(pending)
        ├─ turnId = assistantRecordId  (与 Tencent 一致)
        ├─ launchAgent:
        │    ├─ new OpencodeMessageBuilder({assistantRecordId})
        │    ├─ 每个事件: pushEvent + SSE + stream_events 三路分发
        │    └─ tool_result 触发 flushToDb (里程碑)
        └─ finally:
             builder.finalize(status)
               ├─ setRecordParts(turnId, finalParts)
               └─ finalizePendingRecords(turnId, 'done'|'error'|'cancel')

## 新增

- src/agent/runtime/opencode-message-builder.ts
  class OpencodeMessageBuilder 累积事件 → UnifiedMessagePart[] → 落库
    pushEvent(msg) 按 contentType 分支:
      text chunks 合并到 currentTextBuffer
      tool_use/thinking 前 flush buffer 成 part
      tool_input_update 就地更新原 tool_call part
      tool_result 同步 tool_call 的 status='completed'/'error'
      顺序保持 [text, tool_call, tool_result, text]
    flushToDb() tool_result 里程碑触发
    finalize(status) setRecordParts + finalizePendingRecords
  findLastRecordIds() 找上轮 records 维护 replyTo/parentId 链

- scripts/test-persistence-e2e.mts
  真实 TCB 环境 e2e:prompt → 跑完 → loadDBMessages 读回验证
  + 模拟前端 tasks.ts 转换逻辑不报错
  10/10 断言全 PASS

## 改动

- persistence.service.ts
  新增 public setRecordParts(recordId, parts) — 给非 Claude SDK runtime
  用(它们不写 JSONL,直接一次性替换 parts)

- opencode-acp-runtime.ts
  chatStream 非 resume 分支:
    preSavePendingRecords → turnId = assistantRecordId(替代之前的随机 uuid)
  launchAgent:
    new OpencodeMessageBuilder(envId ? opts : null)
    makeEmitter 接受 messageBuilder 参数
    事件循环:builder.pushEvent + tool_result 触发 flushToDb
    finally:builder.finalize(status 按 completed/abort/error 映射)

## AgentCallbackMessage → UnifiedMessagePart 映射

| 事件         | contentType   | metadata                             |
|--------------|---------------|--------------------------------------|
| text chunks  | text (合并)   | {id, type:'message', role}           |
| thinking     | reasoning     | -                                    |
| tool_use     | tool_call     | {toolCallName, toolName, input, status} + toolCallId |
| tool_result  | tool_result   | {status, isError} + toolCallId       |

## 测试结果

### e2e #42 持久化
```
records count: 2
  - user      status=done  parts=1  [text]
  - assistant status=done  parts=3  [tool_call, tool_result, text]

frontend convert result: 2 TaskMessage
  user parts:  text
  agent parts: tool_call,tool_result,text  ← 前端原样消化

10/10 assertions PASS
```

### 回归
- e2e #1 local: PASS
- e2e ToolConfirm: PASS
- type-check / lint / build / format 全通过

## 与 stream_events 的分工

两条数据通道独立:
- vibe_agent_messages = 永久会话历史(前端 /messages 读)
- vibe_agent_stream_events = 实时 SSE(observe 重连 replay,turn 完成后清理)

## 已知边界

- text-only + server 崩溃 → 丢失尾部 text(stream_events 保底,可重启 replay 补偿)
- 无定时 flush(仅 tool_result + finalize 时写),长 text 可加 5s 定时
## 背景:和 Tencent SDK 的对齐审视

用户提问:"这个持久化和之前的 ToolConfirm/AskUser 能力能对应吗?"
深度调研 Tencent SDK 持久化链路后发现:

我之前只在 tool_result 时 flushToDb。问题:
- AskUserQuestion / ToolConfirm 场景,tool_call 发出后会挂起等待用户答复
- 挂起期间 DB 里 assistant record 是空的 parts(flushToDb 还没触发)
- 前端此时若打开历史,看不到"系统在问你问题"的卡片

Tencent 不受此影响:它通过 SDK JSONL 写 + updateToolResult 立刻同步;
OpenCode 走事件流,需要主动在 tool_use 时触发 flush。

实测 opencode 事件顺序(test-event-order.mjs):
  tool_call (status=pending)   ← 工具调用,execute 即将开始
  [execute 阻塞等用户答复]
  tool_update (status=completed, rawOutput=...)   ← execute 返回

所以 tool_use 是挂起前的"最后一个可见事件",必须立刻 flush。

## 改动

opencode-acp-runtime.ts makeEmitter:
  if (msg.type === 'tool_use' || msg.type === 'tool_result') {
    messageBuilder.flushToDb().catch(...)
  }

## 验证

新增 scripts/test-persistence-suspend-e2e.mts:
  Round 1: LLM 调 AskUserQuestion → 等 ask_user 事件
  ★ 挂起期间 loadDBMessages:
    assistant status=pending parts=[text, tool_call(in_progress)]
    无 tool_result ← 预期行为(还没答)
  Round 2: askAnswers 恢复 → 最终 loadDBMessages:
    assistant status=done parts=[text, tool_call(completed),
                                 tool_result(completed), text(继续)]

所有断言 PASS。

## 额外:关于 Tencent 独有的持久化步骤

调研发现 Tencent SDK 还做:
  - providerData 继承(从原 tool_call 继承 messageId/model/agent)
  - providerData 清理(剥离 SDK 的 skipRun/error deny 标记)
  - Resume 时立刻同步调 updateToolResult

这些是 Claude SDK 的 JSONL 行为兼容需要的(防 deny 死循环)。
OpenCode 不走 JSONL,通过事件流自然同步 tool_result,没有这些兼容性压力。
所以"不实现"是正确的设计简化,文档 §14.10 明确记录了这点。

## 回归

e2e #1 local + ToolConfirm + Persistence 全 PASS
type-check / lint / build / format 全通过
## 动机

OpenCode 每次 chatStream 都 newSession(空白上下文),没有跨轮记忆能力。
Tencent SDK 通过 JSONL 自动恢复上下文,OpenCode 不走 JSONL,需主动注入。

用户场景(e2e 覆盖):
  Turn 1: hello 我叫王小明
  Turn 2: 我叫什么?        ← 没历史注入会答"不知道"
  Turn 3: 用 AskUserQuestion 问 1+1=?
  [模拟用户答 2]
  Turn 4: 总结我的名字和对话历史   ← 必须准确回忆所有前序交互

## 实现

opencode-message-builder.ts 新增 buildHistoryContextPrompt():
  - 从 DB loadDBMessages 读最近 N 轮 status=done 的记录
  - 按 role 拼成 User: / Assistant: / (tool_call:...) / (tool_result:...) 摘要
  - 格式化为 "Below is the prior history.\n[History]\n...\n[Current user message]\n<prompt>"
  - 排除本轮刚 preSave 的 user/assistant record(避免把当前 prompt 当历史)

opencode-acp-runtime.ts:
  - chatStream 记录本轮 preSaved recordIds 传给 launchAgent
  - launchAgent 接受 excludeHistoryRecordIds 参数
  - conn.prompt 之前:envId ? buildHistoryContextPrompt(..., {excludeRecordIds}) : prompt
  - Resume 路径不走这里(resume 时原 opencode session 已有上下文)

## e2e 验证:test-memory-flow-e2e.mts

模拟真实用户流程(4 轮对话 + 1 次中断恢复):

Turn 1: "hello 我叫王小明"
  → "你好,王小明!很高兴..."

Turn 2: "我叫什么"
  → "根据之前的对话记录,你告诉我你叫王小明。"   ← 关键:跨 spawn 记忆

Turn 3: "请用 AskUserQuestion 问 1+1=?,选项 2/3/4"
  → tool_use AskUserQuestion → ask_user 事件 → 挂起
  (DB 此时可见 tool_call(in_progress))

Turn 3 resume: askAnswers={"Math":"2"}
  → tool_result → "你选择了..."

Turn 4: "请总结我的名字和对话历史"
  → 精准列出:
     - "你的名字:王小明"
     - 4 轮对话每轮做了什么
     - "在第三次对话中,我调用了 AskUserQuestion 工具"
     - "你选择了答案 2"

11/11 断言 PASS (包括 Turn 4 回答必须同时含"王小明"和"1+1"/"2")

DB 最终状态:8 条 records 全 status=done
Turn 3 assistant parts = [tool_call(completed), tool_result(completed), text]

## 细节

- 历史文本构造里 tool_result 截 300 字(避免 context 过长)
- thinking part 不放进 history(太长且模型已消化)
- race condition 修复:result 事件到达后再多等 800ms 吸收尾部 text chunk

## 回归

type-check / lint / build / format 全过
e2e local / persistence 全 PASS
优先级顺序:搭测试基础设施 → buildHistoryContextPrompt 格式 → fallbackBins

## 优化 1:vitest 单元测试基础设施(优化3)

packages/server 加 vitest@^3.2.0,vitest.config.ts + scripts,
tsconfig.json exclude __tests__ 目录(不参与 server tsc 编译)。
baseline 1 个测试验证配置正确。

## 优化 2:buildHistoryContextPrompt 格式改进(优化2,借鉴 open-design server.ts)

旧格式:
  User: 你好
  (tool_call: AskUserQuestion {"questions":[...]})  ← JSON 截到 200 字符可能断截

新格式(Markdown 分层,turn 编号,安全截断):
  ## Turn 1
  **User:** 你好
  **Assistant:** 很好!
  [Tool call] AskUserQuestion
    params: question, header, options  ← 只有参数名,无参数值(不会断截)
  [Tool result] AskUserQuestion — completed (32 bytes)  ← 只有摘要,无原始内容
  ---
  ## Current user message
  <new prompt>

单元测试 16 个(buildHistoryContextPrompt.test.ts):
  空历史/envId空/全排除/DB异常 → fallback 到原 prompt
  turn 编号/Markdown 前缀/--- 分隔
  tool_call 安全截断(含参数名、不含参数值)
  tool_result 摘要(name + status + bytes)
  reasoning 跳过 / pending record 跳过 / excludeRecordIds

e2e 验证:test-opencode-runtime.mts PASS + memory-flow LLM 正确记住名字/工具/答案。

## 优化 3:fallbackBins + resolveOnPath(优化1,借鉴 open-design agents.ts)

统一 bin 解析,消除两份独立常量(acp-transport 和 runtime 各自的 OPENCODE_BIN):
  - 新增 resolveOnPath(bin):同步 existsSync,无子进程
  - 新增 getResolvedBin():模块级缓存;fallback 顺序:
      OPENCODE_BIN env → 'opencode' → 'opencode-ai'
  - 删除 runtime 的独立 OPENCODE_BIN 常量
  - isAvailable() 改为 getResolvedBin() !== null(同步,快)
  - spawnLocalOpencode 用 getResolvedBin();找不到时友好错误信息

单元测试 10 个(resolveOnPath.test.ts):
  vi.mock('node:fs') + vi.stubEnv 控制 PATH/OPENCODE_BIN
  PATH 找到/找不到/空 PATH / OPENCODE_BIN env override(存在/不存在) /
  fallback chain / isAvailable true/false

e2e 验证:test-opencode-runtime.mts PASS(spawn 不 break)

## 测试汇总

vitest 单元测试:27 passed
  - baseline: 1
  - buildHistoryContextPrompt: 16
  - resolveOnPath: 10
e2e: test-opencode-runtime PASS

type-check / lint / build / format 全通过
## 场景
用户可以在任务创建表单里切换 Agent Runtime:
- tencent-sdk(CodeBuddy 默认)
- opencode-acp(OpenCode ACP)
- 未来可扩展接入更多 runtime

## 改动

### 前端(零感知降级:< 2 个 runtime 时不显示)

task-form.tsx:
  - TaskFormProps.onSubmit 加 selectedRuntime?: string
  - useEffect 动态 fetch GET /api/agent/runtimes;
    < 2 个 available runtime → 不渲染选择器(零感知)
  - runtime Select 复用 model Select 完全一样的样式(h-7 border-0 text-sm)
  - 插在 "model · runtime" 行,用 · 分隔
  - RUNTIME_LABELS 映射(tencent-sdk → "CodeBuddy (Default)", opencode-acp → "OpenCode ACP")

home-page-content.tsx:
  - handleTaskSubmit 类型加 selectedRuntime?: string
  - 所有 fetch('/api/tasks', ...) body 透传 selectedRuntime(含 multi-repo + multi-agent 路径)

### 后端

db/types.ts:
  - Task interface 加 selectedRuntime: string | null
  - TaskNullableFields 加 'selectedRuntime'

db/schema.ts (drizzle sqlite, dev mode):
  - tasks table 加 selectedRuntime text 列(注释 null = registry default)

db/cloudbase/repositories.ts:
  - normalizeTask 加 selectedRuntime: doc.selectedRuntime ?? null
  - create 默认 selectedRuntime: null

routes/tasks.ts:
  - 接收 body.selectedRuntime 保存到 DB

routes/acp.ts / handleSessionPrompt:
  - 两次 DB 查询合并成一次(减少 I/O)
  - 读 task.selectedRuntime 传给 agentRuntimeRegistry.resolve()
  - 优先级:request params.runtime > task.selectedRuntime > AGENT_RUNTIME env > default

## UI 行为

- 只有 1 个 runtime 时:完全不显示(不影响现有用户体验)
- 有多个 runtime 时:模型选择器后显示 ·  + runtime Select
  ```
  [CodeBuddy · GLM 5.1 · CodeBuddy (Default)▾]
  ```
- 用户选择后随任务一起保存,重新发消息沿用选中的 runtime
- selectedRuntime = '' 或 null 时 registry 按 default 规则选

## 测试

unit tests: 27/27 PASS
lint: PASS
server tsc: PASS
web tsc: PASS
build:server + build:web: PASS
验证 5 个场景:
1. GET /api/agent/runtimes 返回正确列表(tencent-sdk + opencode-acp)
2. POST /api/tasks 不带 runtime → selectedRuntime=null
3. POST /api/tasks 带 runtime=opencode-acp → 正确保存到 DB
4. registry.resolve 按 task.selectedRuntime 选 runtime(无选 → tencent-sdk,有选 → opencode-acp)
5. request params.runtime 优先级高于 task.selectedRuntime(覆盖)
问题:vibe_agent_stream_events 积累了 3600+ 条孤儿数据
根因:
  1. OpenCode runtime launchAgent finally 块从未调 cleanupStreamEvents
     (Tencent SDK runtime 在 cloudbase-agent.service.ts finally 里有调)
  2. e2e 测试大量产生 stream_events 但 agent 未正常结束时没有清理
  3. CloudBase 文档 DB 无 TTL,记录永久保留

修复:在 opencode-acp-runtime.ts 的 finally 块里加与 Tencent 路径对齐的
cleanupStreamEvents 调用(fire-and-forget,不影响主流程)。

已手动清理历史积累的 3614 条孤儿数据。
合并 feat/acp-runtime-abstraction 分支(13 commits)

## 核心能力

- IAgentRuntime 抽象层:server 与 agent 实现解耦,支持多 runtime 热切换
- OpenCode ACP Runtime:opencode acp 子进程,通过 ACP 协议驱动
- 沙箱隔离:全局 tool override (.ts) + env 注入,工具路由到 SCF 沙箱,agent/sandbox 严格分离
- ToolConfirm UI:PendingPermissionRegistry 挂起 + resume,对接现有前端对话框
- AskUserQuestion:custom tool AskUserQuestion + HTTP 内部 endpoint + pending registry
- 消息持久化:OpencodeMessageBuilder → vibe_agent_messages,对齐 Tencent 路径,前端零改动
- 多轮记忆:buildHistoryContextPrompt 跨 spawn 注入历史 transcript
- 前端 Runtime 选择器:GET /api/agent/runtimes 驱动,2+ runtime 才显示,零感知降级

## 技术改进

- vitest 单元测试基础设施(27 个测试)
- buildHistoryContextPrompt Markdown 格式 + 安全截断(参数名不含值)
- fallbackBins + resolveOnPath(支持 opencode-ai 等 PATH 别名)
- stream_events cleanup 修复(3614 条孤儿数据根因)
## 问题

opencode tool override 模板(opencode-tool-templates/*.ts)的参数名和
语义与 opencode builtin tool 不一致,导致 LLM 按 builtin 训练记忆传参时
匹配失败:
  - read/write/edit:builtin 用 filePath,我们用 path → LLM 传错参数名
  - read:builtin offset 1-indexed,我们 0-based → 行偏移错误
  - bash:builtin 有 workdir、description 参数,我们没有
  - grep:builtin 参数名 include,我们用 glob → 字段名不一致

## 修复

所有 6 个 builtin override 文件(read/write/edit/bash/grep/glob)
schema 完全对齐 opencode v1.14.33 src/tool/*.ts:

read:   filePath(非 path); offset 1-indexed; 保留 limit
write:  filePath(非 path); content 语义一致
edit:   filePath(非 path); oldString/newString/replaceAll 语义一致
bash:   +workdir; +description; timeout 默认 120000ms(原 60000)
grep:   include(非 glob); 参数名对齐;内部转换到沙箱 API 的 glob 参数
glob:   pattern/path 语义一致

沙箱模式内部仍正常转发到 SCF,filePath 按相对路径传给沙箱 API。

## 可维护性:版本锁定 + 漂移检测

每个文件顶部加注释:
  // Schema 与 opencode builtin 完全对齐(v1.14.33 src/tool/xxx.ts)

新增 scripts/check-tool-schemas.mts + package.json check:tool-schemas 脚本:
  - 对比本地 opencode --version 与模板注释里的版本
  - 不一致时报警(exit 1),提醒人工对比上游变更并同步
  - 运行:pnpm check:tool-schemas

AskUserQuestion.ts 标注为 custom tool(非 builtin override),不参与
版本比较;注明 schema 来源是 Tencent AskUserQuestionData 契约。
- 合并静态 CodeBuddy 标签和 runtime 选择器为统一 Agent Select
- 新增 MiMo provider(opencode.json + logo + 模型列表)
- Tool 安装从全局 ~/.config/ 改为项目级 .opencode/,注入 OPENCODE_CONFIG_DIR 隔离
- 修复 ACP 完成后 task.status 未更新为 done,导致发送按钮卡住
- per-agent 模型选择,切换 agent 自动校验 selectedModel
- 更新 changelog
Frontend:
- task-form: image paste (Ctrl+V) + file picker button, thumbnail preview with remove
- task-chat: image paste + file picker in follow-up input; images displayed above text
  in user message cards (outside max-h constraint to avoid overlap)
- use-chat-stream: sendMessage/sendInitialPrompt accept imageBlocks
- Images persisted via sessionStorage when task is created, picked up by task-page-client
  and passed to sendInitialPrompt with initialImages

Server/persistence:
- acp.ts: preserve image blocks from prompt (stop filtering to text-only);
  pass imageBlocks to chatStream; set promptCapabilities.image=true
- cloudbase-agent.service.ts: build SDK ImageContentBlock[] from imageBlocks;
  pass as AsyncIterable<UserMessage> to query() for multimodal API calls
- persistence.service.ts: preSavePendingRecords stores image_inline base64 in
  contentBlocks for immediate display; syncMessages() later overwrites with real
  image_blob_ref from SDK JSONL (no local blob write needed)
- tasks.ts: messages endpoint reads both image_blob_ref (local file) and
  image_inline (base64 in DB) from contentBlocks; returns image parts to frontend
  so page refresh shows images

Types:
- MessagePart: add image type { data, mimeType }
- AgentOptions: add imageBlocks
- AcpImageBlock already existed in shared types
- 修复 mcpServers 格式错误:newSession 改回 mcpServers: []
  (沙箱无 standalone MCP HTTP endpoint,custom tool 已通过 env 直连)
- 补充 archiveToGit:agent 完成后归档沙箱变更到 git(对齐 CodeBuddy 行为)
- 前端 TOOL_RENDERERS 新增小写别名(opencode 工具名全小写:todowrite/read/edit/bash 等)
- HIDDEN_TOOLS 新增 todowrite/TodoWrite,避免聊天流重复显示
- task-list-panel deriveTasks 新增 todowrite 解析:todos 数组 → TaskListPanel 面板
  (后续 todowrite 调用覆盖前次快照,支持实时状态更新)
- 新增 e2e 测试脚本:test-opencode-coding-preview-e2e.mts(coding mode + 预览)
  test-opencode-todowrite-e2e.mts(todowrite 工具名验证)
…e 工具

- sandbox-mcp-proxy: 同一 McpServer 同时挂 InMemoryTransport(CodeBuddy SDK)
  和 StreamableHTTPServerTransport(localhost 随机端口),返回 mcpUrl
- opencode-acp-runtime: newSession.mcpServers 注入 cloudbase HTTP MCP server
  type='http', name='cloudbase', url=127.0.0.1:{port}/mcp
- close() 同时关闭 httpTransport + httpServer,生命周期管理完整
…Base 工具

- 新建 cloudbase-mcp-server.ts:独立 stdio MCP server 脚本
  - 从 env 读取 SANDBOX_BASE_URL / SANDBOX_AUTH_HEADERS_JSON / 凭证
  - POST /api/session/env 注入凭证到沙箱
  - mcporter list cloudbase --schema 动态发现工具列表
  - 为每个工具注册 MCP handler(通过 mcporter call)
  - 以 StdioServerTransport 提供 MCP 服务
- opencode-acp-runtime: buildOpencodeMcpServers()
  - 解析 cloudbase-mcp-server.ts 路径(支持 dev/dist)
  - McpServerStdio { command: node, args: [--import tsx/esm, script], env: [...] }
  - 注入沙箱 URL、认证头、CloudBase 凭证、scope ID
  - newSession.mcpServers 传入,opencode 会 spawn 该脚本
- 撤回错误的 HTTP/StreamableHTTPServerTransport 方案(sandbox 无 /mcp endpoint)
- sandbox-mcp-proxy.ts 恢复原状(InMemoryTransport 仅供 CodeBuddy SDK 使用)
… installer

- .opencode/tools/*.ts now checked in directly (no more install step)
- .opencode/opencode.json gitignored; opencode.example.json checked in as template
- opencode-installer.ts simplified to only resolveProjectRoot + getOpencodeConfigDir
- opencode-acp-runtime.ts: remove ensureToolsInstalledOnce and all install machinery
- opencode-tool-templates/ directory deleted (replaced by .opencode/tools/)
- init.mjs: add setupOpencode step (Step 6.5) — copies example, collects OPENCODE_API_KEY + OPENCODE_BASE_URL
- getSupportedModels reads model list from .opencode/opencode.json provider.models
- .env.example: replace MIMO_API_KEY with OPENCODE_API_KEY + OPENCODE_BASE_URL
问题:McpServer 一个实例只能绑定一个 transport(Already connected 报错)

方案:全局单一 HTTP MCP 路由 /cloudbase-mcp
- 复用 server 进程已有的 Express 端口(不额外开 TCP 端口)
- Per-request McpServer + StreamableHTTPServerTransport(stateless)
  每次 HTTP 请求临时创建,请求完成随 GC 回收,零内存积累
- Sandbox 信息通过 headers 传入(X-Sandbox-Url/Auth/Scope-Id)
- 工具 schema 按 scopeId 缓存 30min,避免重复调 mcporter list

文件变更:
- routes/mcp-cloudbase.ts: 全局路由(新建)
  含工具发现(mcporter list)、mcporter call 代理、schema 缓存
- index.ts: 在 authMiddleware 前注册 /cloudbase-mcp 路由
- opencode-acp-runtime.ts: newSession.mcpServers 指向 localhost/cloudbase-mcp
  通过 headers 传入 sandbox 信息 + 可选 API key 鉴权
- sandbox-mcp-proxy.ts: 撤回错误的 HTTP transport 代码,恢复干净的 InMemoryTransport 版本
transport.handleRequest() 已直接写 Node.js ServerResponse,
Hono 收到 Response(null, 200) 后会再次尝试 responseViaResponseObject → writeHead,
导致 ERR_HTTP_HEADERS_SENT 错误。

修复:返回 Response 时带上 x-hono-already-sent: 1 header,
这是 @hono/node-server 的内部信号(responseViaResponseObject line 519),
触发后跳过所有 header/body 写入。
1. 认证:
   - 删除 MCP_API_KEY 环境变量方式
   - 改用当前登录用户的 server API key (sak_xxx)
   - 路由在 authMiddleware 之后,c.get('session') 检查登录态
   - opencode 请求时从 DB 读取用户 apiKey (decrypt) → Authorization: Bearer sak_xxx

2. scopeId → sessionId(仅本地缓存 key,不传给沙箱):
   - sandboxFetch / sandboxBash / discoverCloudbaseTools / mcporterCall 删除 scopeId 参数
   - sandboxAuth 已包含所有沙箱需要的认证和 scope headers(来自 sandbox.getAuthHeaders())
   - buildMcpServer 参数重命名为 sessionId,清晰语义
   - 请求 header 从 X-Scope-Id 改为 X-Session-Id
…envId → scfSessionId

1. /cloudbase-mcp 鉴权改用 sessionJwe(同 storage/presign 机制):
   - base-runtime.setupSandbox() 顶层声明 capturedSessionJwe,返回值新增 sessionJwe 字段
   - opencode-acp-runtime newSession.mcpServers 注入 Cookie: nex_session=<jwe>
     替代之前的 internal token / 用户 API key 方案
   - mcp-cloudbase.ts 简化为只检查 c.get('session'),authMiddleware 通过 cookie 解 JWE
   - 删除 CLOUDBASE_MCP_INTERNAL_TOKEN

2. 重命名 SandboxInstance.envId → scfSessionId(消除歧义):
   - 该字段存的是 SCF session ID(shared 模式=cloudbase envId, isolated 模式=conversationId)
   - 不是用户的 CloudBase 环境 ID(之前命名 envId 容易混淆)
   - 同步更新 buildAuthHeaders/createNewFunction 参数名 + sandbox-mcp-proxy 日志
   - 新增 JSDoc 说明字段语义

3. 调试日志:mcp-cloudbase 路由打印请求头摘要 + 工具发现状态
**Bug**: 前端提交 AskUserQuestion 答案或 ToolConfirm 后,输入框一直显示
"进行中",LLM 收不到答案最终超时。

**Root cause**: handleSessionPrompt 发现 agent 还在 running 就 early return 到
observeStream,完全绕过了 runtime.chatStream 的 resume 分支(askAnswers /
toolConfirmation 必须在那里调 resolvePendingQuestion / resolvePending 来放开
挂起的 opencode tool HTTP 响应 / ACP permission Promise)。

**Fix**: 在 early return 前先判断 hasResumePayload,有 resume payload 时跳过
early return,让请求正常进入 runtime.chatStream 的 resume 分支。

**相关修复**:
- use-chat-stream.ts 的 answers key 改为 question.header(与 opencode custom
  AskUserQuestion tool 的 data.answers[q.header] 对齐)
- apply-session-update.ts 收到 ask_user / tool_confirm / AskUserQuestion tool_call
  时显式 setIsSending(false),解除 UI sending 状态使按钮可点击
  (OpenCode runtime 的 SSE 不会在中断时关闭,不像 CodeBuddy SDK 会 break SSE)

测试验证:登录 yanghang 账号,用 opencode-acp runtime + mimo-v2.5-pro 模型,
触发 AskUserQuestion 后提交 answers,完整流程通过:
1. ✓ chatStream entry isAgentRunning=true hasAskAnswers=true (之前被拦)
2. ✓ resolvePendingQuestion 成功
3. ✓ opencode tool 收到 answers,LLM 继续推理
4. ✓ 返回 "你选择了 **Red**(红色,代表热情与活力)"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant