OpenHands Agent Canvas 工程实践指南:解析 AGENTS.md 的仓库边界、API 访问纪律与 Mock-LLM 端到端测试体系
本文以 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.ts、api/cloud/proxy.ts、api/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-service、agent-server-git-service、skills-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 配套 |
两个值得注意的实现细节:
- 公共 skills 的加载方式已经改变。skills 在构建时从
@openhands/extensionsnpm 包的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。 - 默认工作目录常量。默认 working-dir 回退值为相对路径
workspace/project,由 agent-server-config.ts 以DEFAULT_WORKING_DIR导出:
// src/api/agent-server-config.ts
export const DEFAULT_WORKING_DIR = "workspace/project";
git 路径启发式和默认 PLAN 预览路径都应复用该常量而不是硬编码 /workspace/project。
2.3 版本兼容性的强制下限
前端兼容性由 agent-server-compatibility.ts 的 assertAgentServerVersionIsSupported() 强制,其下限取自 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 的类型化客户端类,绝不使用原始 axios、fetch 或遗留的共享 openHands axios 实例。可用的客户端及子路径导入:
ConversationClient/FileClient/VSCodeClient/ServerClient——@openhands/typescript-client/clientsRemoteWorkspace——@openhands/typescript-client/workspace/remote-workspaceRemoteEventsList——@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 与工作目录,无后端且无覆盖值时抛出 NoBackendAvailableError;getAgentServerHttpClientOptions() 则面向 RemoteEventsList 这类类型化包装器输出 { baseUrl, apiKey, timeout }(默认超时 60000ms),应用代码不得直接导入或构造低层 HttpClient。
允许直接使用 axios 的例外文件(与 CI 守护测试中的 ALLOWED_AD_HOC_HTTP_FILES 白名单一致):
src/api/automation-service/automation-service.api.tssrc/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")、sessionApiKey(authMode === "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.mjs、dev-with-automation.mjs、check-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.agentClientProtocol将agent-client-protocol固定在<0.11,因为 acp 0.11.0 重排了 ACPprompt()参数会破坏 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.mjs 在 uvx 无法生成时(例如 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_REF、OH_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.ts 的 getBakedSessionApiKey() 在环境变量为空时作为回退读取;(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):
- runtime-services-info.mjs 的
buildRuntimeServicesInfo()是无依赖模块,构造 info 对象,也可作为 CLI 供 Docker entrypoint 使用;dev-safe.mjs 为向后兼容重新导出它; - dev-with-automation.mjs 的
buildAutomationRuntimeServicesInfo()在其外层包装 automation 细节,dev-with-automation、dev-static与发布二进制把 JSON 通过--runtime-services-info传给 ingress.mjs 或 static-server.mjs; - ingress 与 static-server 代理真实 agent-server 的
/server_info响应并在配置了该字段时追加runtime_services——版本/工具兼容性字段仍由 SDK 保持权威; - 前端 agent-server-adapter.ts 的
fetchBackendRuntimeServicesInfo()从缓存或新拉取的/server_info读取runtime_services;buildRuntimeServicesSystemSuffix() 渲染<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.json 的
telemetry下。未配置的源码构建使用 staging key 并经由https://z.openhands.dev路由;发布 workflow 通过VITE_POSTHOG_API_KEY传入生产 key;预编译 npm 消费者在运行时经AgentServerUIProviders.analytics或configureTelemetry()覆盖apiKey/apiHost/uiHost; setTelemetryConsent是唯一的用户同意控制器,configureTelemetry(false)是宿主的硬禁用;subscribeTelemetryConsent是唯一的 React 侧同意存储,渲染同意状态的钩子必须使用useSyncExternalStore;canvas_install在同意之前发射一次,带客户端匿名 distinct ID;同意且 Cloud 认证后,Canvas 用稳定的 Cloud 用户 ID 识别 PostHog,从而把此前的匿名活动归并到同一人。只是切换到本地后端会清除 Cloud 事件上下文但不重置已识别身份;- telemetry.ts 在
before_send中追加不可变的client_source、client_version、package_name、package_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_canvas 与 X-OpenHands-Client-Version 头(来自 client-source.ts)——设备码、API key、会话内容、原始 host 等用户数据绝不允许进这些头。生产的 OSS 漏斗使用类型化事件 cloud_device_authorization_started、cloud_device_authorization_succeeded、cloud_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.ts 用 bin/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,默认端口 18300(MOCK_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 变更文件后输出要跑的目录,共四种判定模式:
- 变更文件命中具体
mappings→ 只跑对应子目录 +regressions; - 变更的是 mock-LLM spec 文件本身 → 跑其所在功能子目录 +
regressions(测试-only PR 的新 spec 仍会执行); - 命中
runAllSources横切模式(如src/api/agent-server-adapter.ts、package.json、共享测试辅助)或为未映射的src/文件 → 全量(__ALL__); - 变更在 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.sh 用
while 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:latest,MOCK_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.ts。npm run test:e2e:live -- --check可在不跑测试的情况下校验本地配置;Playwright 标志放--之后(如-- --headed); - 只需一个 LLM 凭据:
LIVE_E2E_LLM_API_KEY、OPENAI_API_KEY、ANTHROPIC_API_KEY或LLM_API_KEY;可选覆盖LIVE_E2E_LLM_BASE_URL、LIVE_E2E_LLM_MODEL、LIVE_E2E_SESSION_API_KEY、LIVE_E2E_BACKEND_URL、LIVE_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 Serverhttp://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.png 与 error-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):
LlmSettingsScreen由getInitialView()检查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-label、title、alt、toast、placeholder)必须经过 react-i18next 的 t(),键为 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.ts 的 AvailableLanguages),npm run make-i18n 重新生成 src/i18n/declaration.ts 与 public/locales/<lang>/openhands.json。命名遵循 CATEGORY$IDENTIFIER 约定(常见前缀:CHAT_INTERFACE$、SETTINGS$、COMMON$、BUTTON$、HOME$、MICROAGENT$ 等)——能复用既有前缀就不新开。eslint.config.js 中 i18next/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_DIR(agent-server-config.ts)、OPENHANDS_I18N_NAMESPACE(src/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.ts、public/locales/<lang>/openhands.json)不手改、不做 lint 目标。新增字符串前先判断归属:用户读 → 规则 1;程序读 → 规则 2;标记联合 → 规则 3。
十一、其他值得深入的关键工程约定
AGENTS.md 的"附加笔记"部分沉淀了大量实战结论,择其要者:
npm test先跑npm run make-i18n,使干净环境在 Vitest 加载别名导入前生成src/i18n/declaration.ts。package.json直接依赖全部精确钉版(无 caret),可复现安装用提交锁定的package-lock.json+npm ci;overrides中保留了针对具体安全公告的范围化覆盖(如@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-backendslocalStorage 键为 null 时,注册表播种一个默认本地后端(iddefault-local,host/api-key 来自 agent-server-config),之后与普通注册后端无异;getEffectiveLocalBackend()返回首个已注册本地后端,注册表无本地时回退合成默认(供 API 客户端获得基线local目标); - 会话历史惰性加载:REST 优先、WebSocket 随后。
useConversationHistory仅拉取最近 50 条(INITIAL_HISTORY_PAGE_SIZE,sort_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)——保持react、react/jsx-runtime、react-dom/client、react-router/dom在optimizeDeps.include;Vercel 部署必须保留build/client完整并包含presets: [vercelPreset()],否则产出空部署。
十二、结语:一份"可执行"的工程宪法
AGENTS.md 的价值在于它把"约定"落到了可验证层面:API 访问纪律有扫描型测试守护,版本下限有 assertAgentServerVersionIsSupported() 强制,字符串规范有 ESLint 规则与白名单文件精确对应,测试选择有 test-mapping.json 驱动,运行时拓扑有 /server_info.runtime_services 的 E2E 断言。对贡献者而言,它同时回答了三个问题——代码该写在哪个仓库、前端如何合法地触碰后端、测试与构建如何在本地/CI/Docker 三条路径上保持一致;对 AI agent 而言,它是比任何口头约定更可靠的仓库行为契约。掌握这套模型,也就掌握了在这个多仓库 AI 开发系统中做前端工程的全部关键约束。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00