首页
/ OpenHands Agent Canvas 工程实践指南:解析 AGENTS.md 的仓库边界、API 访问纪律与 Mock-LLM 端到端测试体系

OpenHands Agent Canvas 工程实践指南:解析 AGENTS.md 的仓库边界、API 访问纪律与 Mock-LLM 端到端测试体系

2026-09-04 15:00:29作者:咎岭娴Homer

本文以 OpenHands 仓库根目录的 AGENTS.md 为蓝本,系统拆解这个 "agent-canvas" 前端仓库的工程治理模型:四个协作仓库的归属边界、两条被 CI 强制守护的 API 访问规则、config/defaults.json 集中配置与本地启动栈、注入 Agent 系统提示词的 <RUNTIME_SERVICES> 机制、Mock-LLM 与 Live 两套端到端测试框架,以及反"魔法字符串"编码规范。读完本文,你将掌握在该项目中安全添加前端代码、调用后端接口、运行与调试 E2E 测试的完整方法论,并能从源码级证据理解每条规则背后的真实实现。

一、仓库定位:agent-canvas 只是多仓库系统的一块拼图

AGENTS.md 开篇即明确:本仓库是 OpenHands 前端(agent-canvas),是"多仓库系统"中的一员。文档用一张"仓库地图"规定了"什么代码该写在这里":

仓库 负责范围 何时把代码加到这里
本仓库(agent-canvas) React/TypeScript 前端:UI、路由、src/api/消费后端 API 的前端服务 修改 UI、前端状态,或前端如何调用既有后端端点
software-agent-sdk Python SDK + agent-server:agents、tools、conversations、events 以及 REST/WebSocket API 面 新增或修改后端端点、agent/tool 行为、服务端逻辑
typescript-client@openhands/typescript-client 镜像 agent-server API 的 TypeScript 客户端 为前端访问 agent-server 端点编写类型化客户端方法
extensions@openhands/extensions 公共 skills、automations、MCP 集成 添加或编辑 skill、automation、集成

文档特别列出三类"常见错位":API 端点访问应写进 typescript-client 再被前端消费,禁止在前端加入原始 axios/fetch 端点代码(CI 守护测试为 no-direct-agent-server-calls.test.ts);新的服务端点/agent 逻辑属于 software-agent-sdk;skills/automations/集成属于 extensions。

从源码结构看,这条边界确实被严格执行。no-direct-agent-server-calls.test.ts 是一个"扫描型"测试:它递归遍历 src/ 下所有非测试源文件,用正则检测 openHands.(共享 axios 实例)、createHttpClient(new HttpClient(、直接 axios(...) 调用、以及 200 字符窗口内 fetch/api/ 路径的写法,命中即收集违规项并断言违规列表为空。白名单只有三个文件:api/automation-service/automation-service.api.tsapi/cloud/proxy.tsapi/main-app-auth.ts——这正是文档"允许例外"一节列出的基础设施级豁免。

文档还强调了一条协作纪律:PR 描述中的 HUMAN: 小节专属于人类贡献者,AI agent 不得增删改移;若 CI 因该小节缺失而失败,应停下来请人类用自己的话补写,而不是代填。所有 PR 还必须遵守 .agents/skills/custom-codereview-guide.md 所要求的"必须留下 APPROVE 或 COMMENT 审查、不能无声结束"的评审规则。

二、前端 API 适配层与部署环境变量

2.1 src/api/ 中的三个关键服务

AGENTS.md 指出前端 API 适配主要位于 src/api/

  • option-service:构造 web-client 配置,通过 @openhands/typescript-client 的 LLM 端点读取 models/providers;
  • settings-service:通过 typescript-client 的 settings API 做持久化,从 /api/settings/agent-schema/api/settings/conversation-schema 读取 schema,带可选 X-Expose-Secrets: encrypted 头获取会话启动 payload 的设置,并用带 diff 的 PATCH 保存设置;
  • event-serviceagent-server-git-serviceskills-service 等:一律经由 @openhands/typescript-client 路由本地 agent-server 访问,而非直接 HTTP。

2.2 部署环境变量

文档列出的受支持环境变量(与 .env.sample 中的注释一致):

变量 作用
VITE_BACKEND_BASE_URL agent server 基础 URL
VITE_SESSION_API_KEY 可选的会话认证密钥
VITE_WORKING_DIR 创建会话时发送的默认工作区路径
VITE_ENABLE_BROWSER_TOOLS=false 让新会话 payload 省略 browser_tool_set 工具
VITE_BASE_PATH 让 SPA 挂载在子路径(如 /canvas)下,需与 scripts/static-server.mjs --base-path 配套

两个值得注意的实现细节:

  1. 公共 skills 的加载方式已经改变。skills 在构建时从 @openhands/extensions npm 包的 SKILLS_CATALOG(导出自 @openhands/extensions/skills)加载;前端的 SkillsService 将目录条目映射为 SkillInfo 并与 agent-server 拉取的用户/项目 skills(load_public: false)合并。内置目录 skills 使用持久化的 enabled_skills 允许列表(默认值来自 DEFAULT_ENABLED_SKILL_NAMES),用户/项目 skills 除非出现在 disabled_skills 中否则保持启用——文档要求把这套逻辑集中在 skill-enablement 工具模块src/utils/skill-enablement.ts)维护。agent-server 不再克隆 extensions 仓库,也不再使用 EXTENSIONS_REF
  2. 默认工作目录常量。默认 working-dir 回退值为相对路径 workspace/project,由 agent-server-config.tsDEFAULT_WORKING_DIR 导出:
// src/api/agent-server-config.ts
export const DEFAULT_WORKING_DIR = "workspace/project";

git 路径启发式和默认 PLAN 预览路径都应复用该常量而不是硬编码 /workspace/project

2.3 版本兼容性的强制下限

前端兼容性由 agent-server-compatibility.tsassertAgentServerVersionIsSupported() 强制,其下限取自 config/defaults.json

"compatibility": {
  "minimumAgentServer": "1.28.0"
}

OptionService.getConfig() 调用 loadAgentServerInfo() 来执行该下限检查、识别不可用/认证失败的服务器,并缓存 usable_tools 用于工具门控。/server_info 引导使用 5 秒超时,并通过后端恢复 UI 呈现"不可用/认证失败/版本不支持"三种状态。

三、API 访问纪律:两条被 CI 守护的铁律

规则 1 —— 访问 agent-server 必须走 @openhands/typescript-client

所有指向本地 agent-server(/api/*/server_info/sockets)的调用必须经过 typescript-client 的类型化客户端类,绝不使用原始 axiosfetch 或遗留的共享 openHands axios 实例。可用的客户端及子路径导入:

  • ConversationClient / FileClient / VSCodeClient / ServerClient —— @openhands/typescript-client/clients
  • RemoteWorkspace —— @openhands/typescript-client/workspace/remote-workspace
  • RemoteEventsList —— @openhands/typescript-client/events/remote-events-list

客户端选项必须经由 agent-server-client-options.ts 中的辅助函数组装,调用方永远不硬编码 URL 或认证 token:

// CORRECT
const data = await new ConversationClient(
  getAgentServerClientOptions(),
).getConversation(id);
const file = await new FileClient(
  getAgentServerClientOptions(),
).downloadTextFile(path);

// WRONG -- 原始 axios/fetch 调用会被 no-direct-agent-server-calls.test.ts 拦截
const data = await axios.get(`${host}/api/conversations/${id}`);

从源码看,getAgentServerClientOptions() 从活动后端注册表(getEffectiveLocalBackend())读取 host、session API key 与工作目录,无后端且无覆盖值时抛出 NoBackendAvailableErrorgetAgentServerHttpClientOptions() 则面向 RemoteEventsList 这类类型化包装器输出 { baseUrl, apiKey, timeout }(默认超时 60000ms),应用代码不得直接导入或构造低层 HttpClient

允许直接使用 axios 的例外文件(与 CI 守护测试中的 ALLOWED_AD_HOC_HTTP_FILES 白名单一致):

  • src/api/automation-service/automation-service.api.ts
  • src/api/cloud/proxy.ts —— 代理信封 POST 本身
  • src/api/main-app-auth.ts —— 本地主应用认证端点

规则 2 —— Cloud 后端路由必须走 callCloudProxy

浏览器对 cloud 后端(app.all-hands.dev)或 cloud 运行时沙箱(*.prod-runtime.all-hands.dev)的任何调用都必须经过 src/api/cloud/proxy.ts 中的 callCloudProxy()——这些源不允许来自 localhost 的 CORS。callCloudProxy 把请求信封 POST 到本地 agent-server 的 /api/cloud-proxy,由服务端转发:

import { callCloudProxy } from "../cloud/proxy";

// CORRECT -- cloud 端点
const result = await callCloudProxy<ResponseType>({
  backend,
  method: "GET",
  path: `/api/v1/app-conversations/search?${params}`,
});

// CORRECT -- cloud 运行时沙箱,会话密钥认证
const result = await callCloudProxy<ResponseType>({
  backend,
  method: "GET",
  hostOverride: buildHttpBaseUrl(conversationUrl),
  path: `/api/git/changes?path=${path}`,
  authMode: "session-api-key",
  sessionApiKey,
});

// WRONG -- 浏览器里直接 fetch/axios 到 cloud 主机会被 CORS 拦截
const result = await axios.get(`${backend.host}/api/v1/app-conversations`);

callCloudProxy 的关键选项:backend(提供 host 与 bearer token 的 cloud Backend 对象)、hostOverride(运行时沙箱调用时替换 backend.host)、authMode"bearer" 默认/"session-api-key" 运行时沙箱/"none")、sessionApiKeyauthMode === "session-api-key" 时必填)。

服务层贯穿云/本地分支的标准写法:

if (getActiveBackend().backend.kind === "cloud") {
  return callCloudProxy({ backend: active, ... });
}
return new ConversationClient(getAgentServerClientOptions()).someMethod(...);

四、集中配置与本地开发栈

4.1 config/defaults.json:单一事实源

defaults.json 是版本固定、端口、持久化路径与包名的唯一来源,当前内容(节选):

{
  "versions": {
    "agentServer": "1.44.0",
    "agentCanvas": "1.16.0",
    "automation": "1.9.0"
  },
  "compatibility": { "minimumAgentServer": "1.28.0" },
  "ports": { "agentServer": 18000, "automation": 18001, "proxy": 8000, "vscode": 8001 },
  "images": {
    "agentServer": "ghcr.io/openhands/agent-server",
    "agentCanvas": "ghcr.io/openhands/agent-canvas"
  },
  "telemetry": { "posthogApiKey": "phc_...", "posthogHost": "https://us.i.posthog.com" }
}

所有消费方都从这里读取:JS 脚本(dev-safe.mjsdev-with-automation.mjscheck-sdk-version-sync.mjs)用 JSON.parse(readFileSync(...));Docker 有一个 config-gen 构建阶段把 JSON 转成可 shell-source 的 /opt/agent-canvas/defaults.env,由 entrypoint.sh 启动时加载;CI workflow 用 node -p 把值提取进 $GITHUB_OUTPUT升级版本只需改 config/defaults.json 一处。

此外该文件还有两处细节值得注意:

  • constraints.agentClientProtocolagent-client-protocol 固定在 <0.11,因为 acp 0.11.0 重排了 ACP prompt() 参数会破坏 SDK 的 ACP 客户端(表现为 ACP error: 2 validation errors for PromptRequest);
  • check-sdk-version-sync.mjs 会检查已发布的 openhands-automation 包与 versions.agentServer 必须一致,不一致即失败。

4.2 启动脚本与 uvx 版本选择

npm run dev 通过 uvx 运行完整本地栈(agent-server + automation 后端 + Vite + ingress 代理),无 Docker 依赖;npm run dev:static 相同但以生产构建服务前端;npm run dev:minimal(即 dev-safe.mjs)只跑 agent-server + Vite。agent-server 版本选择的环境变量(优先级从高到低,与 .env.sample 注释一致):

OH_AGENT_SERVER_LOCAL_PATH   # 本地 software-agent-sdk checkout 绝对路径(最高优先级)
OH_AGENT_SERVER_GIT_REF      # git commit SHA 或分支名(优先于版本)
OH_AGENT_SERVER_VERSION     # 指定 PyPI 版本(如 "1.44.0")
OH_SECRET_KEY               # 设置加密密钥,首次运行自动生成并持久化
SESSION_API_KEY / OH_SESSION_API_KEYS_0 / VITE_SESSION_API_KEY
# 会话密钥,未设置时用 crypto.randomBytes(32) 自动生成

安全模型:启动器生成并持久化 64 字符的 session API key 到 ~/.openhands/agent-canvas/session-api-key.txt(除非被覆盖),agent-server 与 automation 后端共享该会话密钥;OH_SECRET_KEY 独立持久化在 secret-key.txt 以保护设置加密。dev-safe.mjsuvx 无法生成时(例如 PATH 缺失)必须快速失败。

scripts/dev-with-automation.mjs 运行的 ingress 代理路由规则:/api/automation/* → automation 后端(:18001);/api/*/sockets → agent server(:18000);/* → 前端(:3001,Vite 或 static 取决于启动器模式)。环境变量:PORT(ingress 端口,默认取自 defaults.json)、OH_AUTOMATION_GIT_REFOH_AUTOMATION_VERSION。访问点为 http://localhost:8000/(主 UI)与 http://localhost:8000/api/automation/docs

4.3 认证模式:Local 与 Public

  • Local 模式(默认,无 --public):session API key 自动生成并持久化,通过 VITE_SESSION_API_KEY 烘焙进 Vite 开发服务器,或由 static-server.mjs --session-api-key 注入静态构建——用户无需粘贴密钥。
  • Public 模式--public 标志):要求 LOCAL_BACKEND_API_KEY 环境变量。该密钥作为 agent-server 会话密钥(OH_SESSION_API_KEYS_0),但烘焙进前端。前端通过 isAgentServerAuthError() 检测 /server_info 的 401,展示 ApiKeyEntryScreen(复用 BackendForm,host 预填且只读)。提交后密钥持久化到 localStorage['openhands-agent-server-config'] 并重新加载页面。
# 开发
LOCAL_BACKEND_API_KEY=my-secret npm run dev -- --public
# 生产(发布的 npm 包)
LOCAL_BACKEND_API_KEY=my-secret npx @openhands/agent-canvas --public

已发布二进制的认证修复细节:npm 全局安装后运行的预构建前端不含烘焙的 VITE_SESSION_API_KEY(npm publish 构建时没有该变量),运行时密钥由 static-server.mjs --session-api-key 注入一个 <head> 脚本完成两件事:(a) 设置 window.__AGENT_CANVAS_SESSION_API_KEY__,由 agent-server-config.tsgetBakedSessionApiKey() 在环境变量为空时作为回退读取;(b) 把同一密钥写入 localStorage 旧键。若缺少 window-global 路径,全新安装时 makeDefaultLocalBackend() 会返回 null,用户会被 root.tsx 卡在 Manage Backends 弹窗之后。

五、Runtime Services:把服务拓扑注入 Agent 的系统提示词

这是 AGENTS.md 中最具工程巧思的设计之一。当开发启动器(npm run dev / dev:static / 发布的 agent-canvas 二进制)以 ingress/static-server 启动栈时,面向后端的服务器应用会把运行时服务元数据追加为 /server_info 上的可选 runtime_services 字段;前端在创建会话时读取该后端值,并作为 AgentContext.system_message_suffix 转发给 POST /api/conversations——于是新会话的系统提示词末尾会带有一个 <RUNTIME_SERVICES> 块。

完整管道(launcher → backend → frontend → suffix):

  1. runtime-services-info.mjsbuildRuntimeServicesInfo()无依赖模块,构造 info 对象,也可作为 CLI 供 Docker entrypoint 使用;dev-safe.mjs 为向后兼容重新导出它;
  2. dev-with-automation.mjsbuildAutomationRuntimeServicesInfo() 在其外层包装 automation 细节,dev-with-automationdev-static 与发布二进制把 JSON 通过 --runtime-services-info 传给 ingress.mjsstatic-server.mjs
  3. ingress 与 static-server 代理真实 agent-server 的 /server_info 响应并在配置了该字段时追加 runtime_services——版本/工具兼容性字段仍由 SDK 保持权威;
  4. 前端 agent-server-adapter.tsfetchBackendRuntimeServicesInfo() 从缓存或新拉取的 /server_info 读取 runtime_servicesbuildRuntimeServicesSystemSuffix() 渲染 <RUNTIME_SERVICES> markdown 块;buildAgentContext() 将其挂到 agent_context.system_message_suffix

5.1 runtime_services 的 JSON 形态

{
  "mode": "dev:automation",
  "services": {
    "agent_server": {
      "description": "The OpenHands Agent Server this agent is running inside. ...",
      "url_from_agent": "http://localhost:18000"
    },
    "ingress": {
      "description": "Unified entry point. Routes /api/automation/* ...",
      "url_from_agent": "http://localhost:8000"
    },
    "frontend": {
      "kind": "vite",
      "description": "Vite dev server hosting the agent-canvas frontend.",
      "url_from_agent": "http://localhost:3001"
    },
    "automation": {
      "description": "OpenHands Automations service. All routes are mounted under '/api/automation'. Authenticate with header 'X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY'.",
      "url_from_agent": "http://localhost:18001",
      "api_prefix": "/api/automation",
      "docs_url": "http://localhost:18001/api/automation/docs",
      "openapi_url": "http://localhost:18001/api/automation/openapi.json",
      "auth_env_var": "OPENHANDS_AUTOMATION_API_KEY"
    }
  }
}

services 下的所有键都是可选的,对应服务未运行时即省略。frontend.kind"vite"(Vite 开发服务器)或 "static"(服务预构建 build/ 目录的栈)。

5.2 渲染后的 <RUNTIME_SERVICES>

<RUNTIME_SERVICES>
You are running inside an agent-canvas dev stack started in 'dev:automation' mode.
The following services are reachable from your sandbox. URLs are written
from your point of view (i.e., as you should curl/fetch them).

* Agent Server (you): http://localhost:18000
    The OpenHands Agent Server this agent is running inside. Tool calls (terminal, file_editor, browser, etc.) execute here.
* Ingress: http://localhost:8000
    Unified entry point. Routes /api/automation/* to the automation backend, /api/* and /sockets to the agent-server, and /* to the frontend.
* Frontend: http://localhost:3001
    Vite dev server hosting the agent-canvas frontend.
* Automation backend: http://localhost:18001
    OpenHands Automations service. All routes are mounted under '/api/automation'. Authenticate with header 'X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY'.
    Docs:    http://localhost:18001/api/automation/docs
    OpenAPI: http://localhost:18001/api/automation/openapi.json
    Auth:    header 'X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY'

Trust this block over guessing: do not assume any other URLs are running.
In particular, http://localhost:18000 inside your sandbox is the Agent Server
you are running inside of — NOT the automation backend.
</RUNTIME_SERVICES>

文档给 Agent 的行为准则:把该块视为权威——不要为"automation server"硬编码 localhost:8000,也不要探测随机端口;块里说 automation 没运行就跳过 /api/automation 调用;否则使用列出的 url_from_agent + api_prefix(默认 /api/automation)和 X-Session-API-Key 头。E2E 覆盖由 mock-LLM automation 测试通过 getMockLLMRequests() 验证 <RUNTIME_SERVICES> 块确实到达 LLM。

六、遥测架构:一个 PostHog 客户端的所有权模型

AGENTS.md 用一整节约束遥测(PostHog)的职责划分,核心是"单一客户端、集中控制":

  • telemetry.ts唯一访问命名 PostHog 客户端 agent-canvas 的模块。该命名把 Canvas 的身份、持久化、配置与同意和嵌入宿主的默认单例隔离开。React 代码通过该服务声明 Cloud 用户身份与事件上下文,从不直接拿到、识别或重置 SDK 客户端;
  • TelemetryProvider 配置 bootstrap/运行时选项、提前初始化服务,是 useTelemetry() 生命周期的唯一所有者(发射 install/session 事件)。不得在 Canvas 路由或内部组件里单独挂载该生命周期钩子;
  • 默认 PostHog key 与直连 ingestion host 位于 config/defaults.jsontelemetry 下。未配置的源码构建使用 staging key 并经由 https://z.openhands.dev 路由;发布 workflow 通过 VITE_POSTHOG_API_KEY 传入生产 key;预编译 npm 消费者在运行时经 AgentServerUIProviders.analyticsconfigureTelemetry() 覆盖 apiKey/apiHost/uiHost
  • setTelemetryConsent 是唯一的用户同意控制器,configureTelemetry(false) 是宿主的硬禁用;subscribeTelemetryConsent 是唯一的 React 侧同意存储,渲染同意状态的钩子必须使用 useSyncExternalStore
  • canvas_install 在同意之前发射一次,带客户端匿名 distinct ID;同意且 Cloud 认证后,Canvas 用稳定的 Cloud 用户 ID 识别 PostHog,从而把此前的匿名活动归并到同一人。只是切换到本地后端会清除 Cloud 事件上下文但重置已识别身份;
  • telemetry.tsbefore_send 中追加不可变的 client_sourceclient_versionpackage_namepackage_version 属性,使 reset 无法移除归属信息;重复的业务里程碑使用确定性的 PostHog $insert_id 值而非进程本地缓存;
  • React 应用事件使用 use-tracking.ts 中的类型化函数,组件从不裸调 posthog.capture();非 React 状态机使用 cloud-funnel-analytics.ts 的类型化函数。业务里程碑只有一个规范事件捕获点,不得在 telemetry 与 app 客户端之间条件切换或重复发射。

新增事件的三步流程:1) 在 use-tracking.ts 中添加类型化函数;2) 加入该 hook 的 return 对象;3) 组件中解构调用:const { trackFoo } = useTracking()

Cloud 漏斗可观测性

OAuth 设备授权与 Cloud 会话创建请求携带粗粒度的 X-OpenHands-Client: agent_canvasX-OpenHands-Client-Version 头(来自 client-source.ts)——设备码、API key、会话内容、原始 host 等用户数据绝不允许进这些头。生产的 OSS 漏斗使用类型化事件 cloud_device_authorization_startedcloud_device_authorization_succeededcloud_conversation_ready,React 侧通过 useTracking 发射规范的 backend_added 事件。

事件字典示例:onboarding_link_clicked

所有 onboarding 链接/CTA 点击共用一个稳定事件;新链接必须复用该契约(扩展 use-tracking.ts 中的联合类型),绝不为每个目的地加一次性事件。属性全部是受控枚举或布尔——绝不带原始目标 URL、查询参数或链接文本:

属性 取值
link_id configure_llm | start_conversation | schedule_task | customize_agent | connect_mcp | join_slack | open_docs
destination_type community | integration | documentation | settings | conversation | automation
surface landing_checklist | onboarding_modal(保留)
checklist_item(可选) 所属清单条目的 link_id,所有 landing_checklist 发射都带
step_id(可选) 预留给未来的 onboarding 模态链接
is_external 布尔:目标是否离开应用

侧边栏 "Getting started" 清单的六个条目各自有行链接 + 预览 CTA(故意共享一个 link_id,因为目的地相同)与文档链接(统一 open_docs)。已知限制:中键(auxclick)打开不会被捕获——追踪仅用 React onClick,且从不阻止默认导航。

七、Mock-LLM E2E 测试框架:无真实凭据的完整栈验证

Mock-LLM 测试位于 tests/e2e/mock-llm/,通过 npm run test:e2e:mock-llm 运行,特点是零真实 LLM 凭据却验证从浏览器、经真实 agent-server 到脚本化 mock LLM 服务器的完整栈。

7.1 生产保真启动与单入口 URL

playwright.mock-llm.config.tsbin/agent-canvas.mjs 启动完整 agent-canvas 栈——与用户 npx @openhands/agent-canvas 执行的是同一个二进制:预构建静态前端 + static-server + uvx 拉起的 agent-server + uvx 拉起的 automation 后端 + ingress 代理,全部挂在单个端口之后。前置条件是 build/ 目录(缺 build/index.html 时 webServer 命令会自动 npm run build:app,但 CI 应显式构建以便缓存)。测试的浏览器 baseURL 与后端断言 BACKEND_URL 使用同一个 ingress URL,默认端口 18300MOCK_LLM_INGRESS_PORT 可覆盖)。

状态隔离:OH_CANVAS_SAFE_STATE_DIR=.tmp/mock-llm-state 把测试状态与用户真实的 ~/.openhands/agent-canvas/ 隔开;automation 数据库位于 dirname(STATE_DIR)/automation/automations.db,与 Docker 的布局镜像。每次运行生成随机 session key,经 SESSION_API_KEY / OH_SESSION_API_KEYS_0 / VITE_SESSION_API_KEY 传入栈,static server 在伺服时注入 index.html,前端自动认证。

7.2 Mock LLM 服务器的管理 API

tests/e2e/mock-llm/scripts/ 下的 Python mock 服务器用 openhands-sdk 的 TestLLM 返回脚本化的 tool-call + 文本轨迹,并支持动态轨迹管理:

  • POST /admin/reset —— 重置为默认轨迹(terminal printf + 文本回复),同时清空 completion 请求历史;
  • POST /admin/trajectory/register —— 注册命名轨迹(JSON body:{name, turns},每个 turn 是 {tool_call: {name, arguments}}{text: "..."});
  • POST /admin/trajectory/activate —— 激活已注册的轨迹;
  • GET /admin/requests —— 返回自上次 reset 以来捕获的全部 /v1/chat/completions 请求体(图像上传测试用它验证图像确实转发了给 LLM)。

还有一个细节:agent-server ≥ 1.43 在保存 LLM profile 时会发送 1 token 的 ping completion(canvas 侧 30 秒预算)。mock 用 canned pong 应答而不是喂给 TestLLM——既不消耗脚本化回合,也不会在轨迹耗尽时 500 重试直到超时;该请求也不计入 /admin/requests 历史。

另一个关键机制是内部 LLM 调用的 padding:agent-server 在 skills 激活时、agent 主循环开始前会做一次内部 LLM 调用(condenser/skill-analysis),消耗一个轨迹响应——automation 测试会前置一个一次性 { text: "" } 响应作垫;conversation 测试不需要,因为其用户消息不触发 skill 激活。

7.3 选择性测试执行

测试规格按功能子目录组织,镜像源码结构,使 CI 可以按变更文件选择性地跑:settings/conversations/files/automations/onboarding/backends/home/mcp/skills/canvas-extensions/regressions/(CSS 隔离、事件分页、工作区持久化回归,选择性运行时始终包含)。

test-mapping.json 把源码路径映射到测试子目录,resolve-affected-tests.mjs 读取 PR 变更文件后输出要跑的目录,共四种判定模式:

  1. 变更文件命中具体 mappings → 只跑对应子目录 + regressions
  2. 变更的是 mock-LLM spec 文件本身 → 跑其所在功能子目录 + regressions(测试-only PR 的新 spec 仍会执行);
  3. 命中 runAllSources 横切模式(如 src/api/agent-server-adapter.tspackage.json、共享测试辅助)或为未映射的 src/ 文件 → 全量(__ALL__);
  4. 变更在 E2E 相关树之外(docs、specs)→ 什么都不跑;workflow 仍启动使必需检查不悬空,重测试任务由内部变更检测器跳过。workflow_dispatch 始终跑全量。

测试串行执行(workers: 1,每个 describe 块 mode: "serial"),每个 spec 自包含(配置自己的 LLM profile,afterEach 重置 mock LLM 到默认轨迹)。CI workflow(mock-llm-e2e.yml)刻意不用 pull_request.paths 过滤——路径跳过的 workflow 可能让必需检查悬空 pending;改由轻量 detect-pr-changes 作业把重任务标记为 skipped-success。报告经 render-mock-llm-report.mjs 折叠进 <details> 块,upsert-pr-comment.mjs 先删除同一作业的旧评论再发新评论,避免评论堆积;新增 spec 文件的测试在 PR 评论中打 🆕 徽标。

7.4 Docker 镜像测试(共享 spec)

同一批 spec 与辅助函数经 playwright.mock-llm-docker.config.ts 复用验证 Docker 镜像(npm run test:e2e:mock-llm:docker,需要 Docker daemon 与已构建镜像)。架构差异:Docker 配置用 docker run --network host 替换 npm 路径的 webServer;Linux 上 host 网络让容器与宿主共享网络栈,127.0.0.1 URL 行为一致;macOS/Windows 桥接网络需设 MOCK_LLM_AGENT_URL=http://host.docker.internal:<port>

两个值得学习的工程细节:

  • 双栈绑定:static-server 与 ingress 默认绑 ::(双栈),entrypoint.sh 显式传 --host ::,因此 localhost 无论解析到 127.0.0.1 还是 ::1 都能连接;
  • entrypoint 崩溃韧性entrypoint.shwhile kill -0 "$STATIC_PID"; do sleep 10 & wait $!; done 循环而非 wait -n(任意子进程)。agent-server 或 automation 后端中途退出时 static-server 代理仍存活并对后端路由返回 502——容器不会随 ECONNREFUSED 消失;sleep & wait $! 模式保证 wait 是前台操作,被 trap 的信号立即触发。

mock-llm-helpers.ts 导出两个 URL 常量:MOCK_LLM_BASE_URL(恒为 http://127.0.0.1:<port>,测试管理 API 用)与 MOCK_LLM_AGENT_URL(默认同上,可覆盖——这是配置 LLM profile 时 agent-server 做推理调用的 URL)。Docker 镜像标签默认 ghcr.io/openhands/agent-canvas:latestMOCK_LLM_DOCKER_IMAGE 可覆盖;需要读取宿主文件的 skills 测试用卷挂载(.tmp/mock-llm-skill-repos/ → 容器内 /tmp/mock-llm-skill-repos/ 等)。

八、Live E2E 测试框架:真实 LLM 的隔离 QA 通道

Live QA 路径刻意与普通 mocked Playwright 覆盖分开,绝不允许 live LLM 测试混入 npm run test:e2e

  • Live 测试位于 tests/e2e/live/,只经 npm run test:e2e:live(即 playwright.live.config.ts)运行;主会话冒烟测试是 real-agent-server-conversation.spec.ts
  • 该命令经 Node --env-file-if-exists 加载 .env 并调用 runner 脚本,后者校验本地环境、解释缺失凭据,然后跑 playwright test --config=playwright.live.config.tsnpm run test:e2e:live -- --check 可在不跑测试的情况下校验本地配置;Playwright 标志放 -- 之后(如 -- --headed);
  • 只需一个 LLM 凭据:LIVE_E2E_LLM_API_KEYOPENAI_API_KEYANTHROPIC_API_KEYLLM_API_KEY;可选覆盖 LIVE_E2E_LLM_BASE_URLLIVE_E2E_LLM_MODELLIVE_E2E_SESSION_API_KEYLIVE_E2E_BACKEND_URLLIVE_E2E_FRONTEND_PORT。本地 runner 打印缺失变量名但不打印秘密值;
  • 配置真实本地栈:live 配置通过 npm run dev:minimal 拉起真实 Agent Server/UI 栈(非 MSW mock)。设了 LIVE_E2E_SESSION_API_KEY 就用它,否则每次运行生成随机密钥;需要直接后端请求的 spec 必须用 routeBackendSessionApiKey(page) 只对配置的后端源注入 X-Session-API-Key,绝不走全局 extraHTTPHeaders。live 测试默认前端端口 3101、Agent Server http://127.0.0.1:18100,避免误用普通开发栈;
  • 测试设计"便宜且尽量确定":让模型执行精确的 EXPECTED_BASH_COMMAND,等待 bash 输出 token 出现在用户消息之外的 UI 中,经真实 Agent Server 事件 API 确认成功的 ExecuteBashObservation/TerminalObservation,再等待最终 EXPECTED_REPLY_TOKEN——一次跑通真实 UI、设置 API、会话创建、websocket 事件路径、终端工具执行与 LLM 响应。即便 temperature 0,LLM 行为也不完全确定,CI 因此保留一次重试;
  • 绝不污染分析:live 配置以 VITE_DO_NOT_TRACK=1 启动应用,live 辅助在应用代码运行前向 local storage 写入遥测退出值,每个 spec 在导航前安装 guardAgainstPostHogRequests(page) 拦截任何指向 PostHog 域或 z.openhands.dev 的请求并令测试失败;
  • CI 触发收窄:live E2E 只在 PR 级运行——手动 workflow_dispatch(要求 pr_number)或带 live-e2e 标签的同仓库 PR。必须在 checkout PR 代码之前跳过 fork PR,使 LLM 凭据与 artifact-push token 永不暴露给不受信任的代码。密钥不进作业级 env,只注入真正跑测试的可信步骤。报告 artifact 上传 HTML 报告 + 截图/视频,用 ffmpeg 把 WebM 转 GIF 供 PR 评论内联;Playwright trace 捕获保持关闭——因为设置流程会把 LLM 凭据发给 Agent Server,trace 可能记录请求体。内联 PR 评论媒体存为 PR 分支上 .pr/live-e2e/<github_run_id>/ 的 PR-only 文件,由 pr-artifacts.yml 在 PR 批准后清理(fork PR 需手动清理)。

九、调试 E2E 测试失败的标准工作流

AGENTS.md 给出了四步诊断法:

1. 先读 PR 评论。 mock-LLM workflow 会在 PR 上发结构化评论:测试结果表、通过/失败状态、可折叠的失败详情(含 Playwright 错误信息)。错误信息通常能区分 locator 不匹配、超时还是元素缺失。

2. 下载 CI artifacts。 每次失败运行都上传(npm 为 mock-llm-e2e-results,Docker 为 mock-llm-docker-e2e-results):

gh run download <run_id> --repo OpenHands/agent-canvas --name mock-llm-e2e-results --dir /tmp/artifacts

其中 test-results-mock-llm/ 含每测试目录的 test-failed-N.pngerror-context.md(失败时刻页面的 YAML 可访问性树快照 + 标出失败行的测试源码);playwright-report-mock-llm/ 是完整 HTML 报告(npx playwright show-report 查看)。

3. 检查 error-context.md 页面快照——"单一最有用的 artifact",它精确展示失败时刻 DOM 里有什么、哪个 tab 被选中、输入框里是什么文本、组件是否渲染。

4. 常见失败模式(文档总结了四类,均有实战细节):

  • "element(s) not found":组件未渲染(条件渲染路径未走通)、name/data-testid 与预期不同、或位于未解析的懒加载边界之后;
  • 串行测试的陈旧状态:mock-LLM 测试串行跑在真实 agent-server 上,前序测试持久化的设置会让表单进入不同视图模式。用 page.route() 拦截并规范化设置响应——例如 onboarding-helpers.ts 中的 routeOnboardingLlmCatalog 拦截 GET /api/settings 清空 llm_base_url,让 LLM 表单始终以 "Basic" 视图打开;
  • 视图模式不匹配(Basic vs Advanced)LlmSettingsScreengetInitialView() 检查 currentSettings.llm_base_url 决定——非默认 base URL 触发 "Advanced" 视图(纯文本输入)而非 ModelSelector 下拉;
  • page.route() 必须在 page.goto() 之前注册,且非 GET 方法应使用 route.fallback() 放行到真实服务器。

本地运行命令:

npm run test:e2e:mock-llm                    # 全量
npm run test:e2e:mock-llm -- --headed        # 浏览器中观察
npm run test:e2e:mock-llm -- -g "test name"  # 按名跑单个测试

十、测试规则与反"魔法字符串"规范

10.1 测试规则(TDD)

文档以 <TESTING_RULES> 块给出硬性规则:为行为变更创建 TDD 测试,聚焦用户行为;AAA 结构(Arrange/Act/Assert)、清晰的测试焦点、恰当的数据管理。写测试之前:不重复已有用例;同一条件不重复断言不 mock hook 本身,mock hook 依赖的底层服务;优先扩展已有测试文件;避免脆弱的视觉呈现断言(功能性 CSS 契约如样式作用域可以直接测);用例数量取覆盖目标变更的最小值。

10.2 规则 1 —— 面向用户的字符串走 i18n

所有可见字符串(按钮标签、标题、校验消息、aria-labeltitlealt、toast、placeholder)必须经过 react-i18nextt(),键为 I18nKey 枚举成员:

// CORRECT
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";

const { t } = useTranslation("openhands");
return (
  <button aria-label={t(I18nKey.CHAT$DISMISS_LABEL)}>
    {t(I18nKey.CHAT$DISMISS)}
  </button>
);

// WRONG -- 每个地区都发英文;被 i18next/no-literal-string 标记
return <button aria-label="Dismiss">Dismiss</button>;

键一次性声明在 translation.json(覆盖 15 种受支持语言,见 src/i18n/index.tsAvailableLanguages),npm run make-i18n 重新生成 src/i18n/declaration.tspublic/locales/<lang>/openhands.json。命名遵循 CATEGORY$IDENTIFIER 约定(常见前缀:CHAT_INTERFACE$SETTINGS$COMMON$BUTTON$HOME$MICROAGENT$ 等)——能复用既有前缀就不新开。eslint.config.jsi18next/no-literal-string 对配置的 JSX 文本与属性设为 "error"

10.3 规则 2 —— 非 UI 标识符用命名常量

程序读取而用户看不到的字符串(存储键、事件名、query key、路由路径、环境变量名、头名称、硬编码路径、特性开关标识符)应在拥有该概念的最贴近模块声明为单一命名常量;超过两个调用方时集中到 *-keys.ts / *-constants.ts

// CORRECT
const ONBOARDING_COMPLETED_KEY = "openhands-onboarded";
localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");

// CORRECT -- query key 走 SETTINGS_QUERY_KEYS / SECRETS_QUERY_KEYS / …
//            位于 src/hooks/query/query-keys.ts(no-restricted-syntax 强制)
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEYS.all });

// WRONG -- 跨文件重复的字面量,无编译期链接,静默拼写错误风险
localStorage.setItem("openhands-onboarded", "true");
queryClient.invalidateQueries({ queryKey: ["settings"] });

仓库既有常量:DEFAULT_WORKING_DIRagent-server-config.ts)、OPENHANDS_I18N_NAMESPACEsrc/i18n/index.ts)、BUNDLED_BACKEND_ID(后端注册表)、*_QUERY_KEYS 辅助。

10.4 规则 3 —— 可辨识联合标签用字符串字面量类型

当字符串是可辨识联合的一部分(事件种类、后端种类、tab ID、agent 状态、观测结果状态),类型本身应约束字面量:

// CORRECT
type BackendKind = "local" | "cloud";
if (backend.kind === "cloud") { … }

// WRONG -- backend.kind 类型为 string;"clould" 也能编译通过
if (backend.kind === "clould") { … }

允许例外:测试夹具(__tests__/tests/e2e/)可用内联字面量;不可本地化的显示字形(键盘快捷键 ⌘↩ 等)可在单行 eslint-disable-next-line 后保留内联(绝不放宽到文件级);生成文件(declaration.tspublic/locales/<lang>/openhands.json)不手改、不做 lint 目标。新增字符串前先判断归属:用户读 → 规则 1;程序读 → 规则 2;标记联合 → 规则 3。

十一、其他值得深入的关键工程约定

AGENTS.md 的"附加笔记"部分沉淀了大量实战结论,择其要者:

  • npm test 先跑 npm run make-i18n,使干净环境在 Vitest 加载别名导入前生成 src/i18n/declaration.tspackage.json 直接依赖全部精确钉版(无 caret),可复现安装用提交锁定的 package-lock.json + npm cioverrides 中保留了针对具体安全公告的范围化覆盖(如 @vercel/static-config > ajv 限定作用域,避免顶层覆盖破坏 ESLint 需要的 ajv 6.x API);
  • MSW mock 模式npm run dev:mock)的 handler 必须覆盖前端适配后实际使用的直连 agent-server 路由:/server_info/api/llm/models/verified/api/llm/providers、两个 settings schema 端点、settings/secrets CRUD、会话浏览、运行时 git 面板;MSW handler 导入的 fixture 必须位于 src/fixtures/.dockerignore 排除 tests/__tests__/,从这些目录导入会导致本地能构建而镜像内 UNRESOLVED_IMPORT);
  • 后端注册表:不再有独立的 "bundled" 后端。首次读取 openhands-backends localStorage 键为 null 时,注册表播种一个默认本地后端(id default-local,host/api-key 来自 agent-server-config),之后与普通注册后端无异;getEffectiveLocalBackend() 返回首个已注册本地后端,注册表无本地时回退合成默认(供 API 客户端获得基线 local 目标);
  • 会话历史惰性加载:REST 优先、WebSocket 随后。useConversationHistory 仅拉取最近 50 条(INITIAL_HISTORY_PAGE_SIZEsort_order='TIMESTAMP_DESC' 后反转),上滚分页由 useLoadOlderEvents 驱动;主 WebSocket 等 REST 落定后以 resend_mode='since' + after_timestamp 连接;事件存储有批量 addEvents 且按 eventIds 去重;
  • 聊天流 action 分组group-events.ts 把连续可分组事件折成 RenderedItem 组(阈值 EVENT_GROUP_MIN_SIZE 当前为 2),EventGroup 组件默认折叠,完成时显示成功勾选头;挂在 ActionEvent 上的 thought 会被提升到组外渲染且按 action ID 去重;
  • CSS 隔离(可嵌入/托管场景):全部打包 CSS 经 vite.config.ts 中的 postcss-prefix-selector 前缀到 [data-agent-server-ui]:root/html/body 等全局选择器由 transformAgentServerUISelector() 直接重映射到作用域壳上;嵌入入口用 AgentServerUIProviders(默认开启作用域根)或 AgentServerUIRoot;主题定制通过 --oh-* CSS 变量暴露;
  • Electron 桌面版electron/main.mjs 用两段式等待解决启动竞态——第一阶段确认 ingress 代理就绪,第二阶段轮询 ${ingress}/server_info 直到返回 200(或 401,证明代理触达了真实 agent-server)才加载 BrowserWindow,且 agent-server 超时下限不得低于约 3 分钟(uvx 冷启动真实需要那么久);打包侧 afterPack 钩子剥离被 app-builder 错误拖入的 ~598 MB 根 node_modules 只恢复 ~200 KB 运行时闭包;scripts/download-node.mjs 捆入真实 Node 发行版(默认 22.12.0)以解决打包应用从 Finder 启动时最小 PATH 导致 node/npx 不可见、stdio MCP 服务器全部 ENOENT 的问题;
  • tools/ 目录:agent-server 可导入的 Python 模块,经 OH_EXTRA_PYTHON_PATH 暴露;canvas_ui_tool.py 通过 --import-modules canvas_ui_tool 在启动时导入,除注册 canvas_ui 外还注册 SDK 内建 FinishTool,使 openhands-automation ≥ 1.9.0 的预置(以 finish_tool_response_schema=TaskOutcome 派发远程会话)不再因 ToolDefinition 'FinishTool' is not registered 全部失败;
  • Vite 开发模式首次加载可能因核心客户端入口依赖未被预打包而黑屏(504 Outout Optimize Dep)——保持 reactreact/jsx-runtimereact-dom/clientreact-router/domoptimizeDeps.include;Vercel 部署必须保留 build/client 完整并包含 presets: [vercelPreset()],否则产出空部署。

十二、结语:一份"可执行"的工程宪法

AGENTS.md 的价值在于它把"约定"落到了可验证层面:API 访问纪律有扫描型测试守护,版本下限有 assertAgentServerVersionIsSupported() 强制,字符串规范有 ESLint 规则与白名单文件精确对应,测试选择有 test-mapping.json 驱动,运行时拓扑有 /server_info.runtime_services 的 E2E 断言。对贡献者而言,它同时回答了三个问题——代码该写在哪个仓库、前端如何合法地触碰后端、测试与构建如何在本地/CI/Docker 三条路径上保持一致;对 AI agent 而言,它是比任何口头约定更可靠的仓库行为契约。掌握这套模型,也就掌握了在这个多仓库 AI 开发系统中做前端工程的全部关键约束。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
903
1.82 K
docsdocs
暂无描述
Markdown
888
5.78 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.51 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.17 K
341