Merge remote-tracking branch 'origin/v0.2' into fix/v02-workbench-orphan-active-trace

# Conflicts:
#	docs/reference/spec-v02-hwlab-cloud-api.md
#	internal/cloud/access-control.test.ts
#	internal/cloud/server-agent-chat.test.ts
This commit is contained in:
Codex
2026-06-02 12:13:50 +08:00
22 changed files with 945 additions and 104 deletions
+4 -5
View File
@@ -155,11 +155,10 @@ workspace;除非 Pod 重建、Codex supervisor 重启、用户显式清空对
可用时优先使用;server 内存 session 因 Pod/replica 切换丢失时,只要本地 session 未过期,
刷新页面不得要求用户重新登录。
当后端使用 AgentRun v0.1 短连接 runner 时,Web 仍可把历史 `threadId` 作为会话连续性元数据
提交,但 HWLAB adapter 不得把该历史 `threadId` 直接透传给新 runner 做 Codex app-server
`thread/resume`。AgentRun runner Job 是新执行面,旧 Web/Codex thread 可能没有对应 rollout
adapter 应保留 `conversationId/sessionId` 和 redacted `requestedThreadId` 用于追踪,同时让新
runner 建立自己的 app-server thread,避免 `no rollout found for thread id`
当后端使用 AgentRun v0.1 短连接 runner 时,Web 必须把当前 conversation 的 `threadId` 作为
唯一会话连续性字段提交;HWLAB adapter 必须把它写入 AgentRun command `payload.threadId`
`SessionRef.threadId`,runner 只按该标准字段执行 Codex app-server `thread/resume`。resume 失败
必须作为标准 `threadId` 路径上的结构化错误/trace 事件暴露。
恢复出的历史消息如果携带 `traceId`,前端必须先对 `/v1/agent/chat/result/<traceId>`
终态归一,再回放 `/trace`。旧的 `running` 消息不能只因为 localStorage restore 被降级成
+1 -1
View File
@@ -119,7 +119,7 @@ G14 Code Agent 通过同一个 repo-owned Codex app-server stdio runner 承载
| `codex-api` | 否 | `HWLAB_CODE_AGENT_CODEX_API_MODEL`,默认 `gpt-5.5` | `HWLAB_CODE_AGENT_CODEX_API_BASE_URL`G14 默认应指向同 Pod `127.0.0.1` loopback forwarderforwarder upstream 为 hyueapi | 独立 Codex/OpenAI-compatible Responses API 通道;不得依赖 DeepSeek bridge。 |
| `minimax-m3` | 否 | `HWLAB_CODE_AGENT_MINIMAX_M3_MODEL`,默认 `MiniMax-M3` | 不由 HWLAB 配置;由 AgentRun `backendProfile=minimax-m3` 的 Secret/profile 承接 | HWLAB 只把该 profile 委托给 AgentRun v0.1,不在 HWLAB 新增 MiniMax API key、base URL、proxy 或 provider 实现。 |
`hwlab-cloud-api` 仍以 `HWLAB_CODE_AGENT_PROVIDER=codex-stdio` 运行。`HWLAB_CODE_AGENT_MODEL``HWLAB_CODE_AGENT_OPENAI_BASE_URL` 可作为 runtime-default 兜底,但前端默认 profile 是 `deepseek`,用户可以切到 `codex-api``minimax-m3`。Codex app-server 启动参数必须同时设置 provider base URL、provider `name``model``review_model`,否则 Codex/DeepSeek 链路可能在模型目录或 `/v1/responses` 阶段退化成 `model=None``minimax-m3` 是 AgentRun 委托 profileHWLAB 只负责把用户选择透传为 AgentRun `backendProfile=minimax-m3`,不得为了该 profile 在 HWLAB 里重新读取 MiniMax Secret 或维护第二套 provider bridge。G14 cloud-api 的 `NO_PROXY/no_proxy` 必须包含 `hyueapi.com``.hyueapi.com``codex-api` 的 hyueapi upstream 由同 Pod loopback forwarder 直连,不能被 proxy 注入污染。
`hwlab-cloud-api` 仍以 `HWLAB_CODE_AGENT_PROVIDER=codex-stdio` 运行。`HWLAB_CODE_AGENT_MODEL``HWLAB_CODE_AGENT_OPENAI_BASE_URL` 可作为 runtime-default 兜底,但前端默认 profile 是 `deepseek`,用户可以切到 `codex-api``minimax-m3`。Codex app-server 启动参数必须同时设置 provider base URL、provider `name``model``review_model`,否则 Codex/DeepSeek 链路可能在模型目录或 `/v1/responses` 阶段退化成 `model=None``minimax-m3` 是 AgentRun 委托 profileHWLAB 只负责把用户选择透传为 AgentRun `backendProfile=minimax-m3`,不得为了该 profile 在 HWLAB 里重新读取 MiniMax Secret 或维护第二套 provider bridge。HWLAB 对外的 conversation/session/thread 合同不随 AgentRun backend 切换而改变;adapter 内部传给 AgentRun 的 `sessionRef.sessionId` 必须按 `backendProfile` 分域,`accessController` 持久化记录仍以 HWLAB 原始 session 归属为准,嵌套 `agentRun.sessionId` 保存 AgentRun scoped sessionRef。G14 cloud-api 的 `NO_PROXY/no_proxy` 必须包含 `hyueapi.com``.hyueapi.com``codex-api` 的 hyueapi upstream 由同 Pod loopback forwarder 直连,不能被 proxy 注入污染。
G14 `codex-api` 不得把 `http://172.26.26.227:17680/v1/responses` 作为默认 base URL;该地址只保留为 D601 legacy Code Queue runner 或历史 egress 对照线索。G14 上的 `codex-api` 应先进入同 Pod `127.0.0.1` loopback forwarder,再由 forwarder 直连 `hyueapi.com` / `.hyueapi.com`;这两个域名必须同时进入 `NO_PROXY``no_proxy`。DeepSeek profile 可以通过集群内 bridge/Moon Bridge 转换,`codex-api` 不能用 DeepSeek bridge 伪装通过,也不能因为默认 profile 切到 DeepSeek 而删除或覆盖 `codex-api` 的独立模型、base URL、auth 和最小闭环验证。
+2 -2
View File
@@ -252,14 +252,14 @@ manages: many devicePodId
- code agent 不能通过修改本地文件改变 gateway session、resource、host workspace、probe UID 或 UART port。
- `hwlab-device-pod` 不接受无内部服务凭据的 profile snapshot 或 job 请求。
- `hwlab-device-pod` 一个实例可以列出并执行多个 `devicePodId` 的状态/job。
- cloud-api compatibility fallback 只能返回 blocked authority payload,不得合成 fake device pod 数据或作为正式 device-pod DEV-LIVE 证据。
- cloud-api legacy compatibility entry 只能返回 blocked authority payload,不得合成 fake device pod 数据或作为正式 device-pod DEV-LIVE 证据。
- 强副作用 job 必须有 reason,并在物理互斥需要时获取 `device_leases`
- `POST /v1/device-pods/{devicePodId}/leases` 只对已授权 actor 创建或刷新互斥租约,响应只返回一次性 `leaseToken`;后续强副作用 job 必须通过 `leaseToken``x-hwlab-device-lease-token` 证明持有租约。
- 撤销 device pod grant 必须释放该用户对同一 `devicePodId` 的活动 lease。
## CLI 实现口径
`tools/device-pod-cli.ts` 是 v0.2 正式 CLI 实现;HWLAB code-agent runner 内的稳定短入口是 `hwpod``/app/skills/device-pod-cli/scripts/device-pod-cli.mjs` 只作为兼容启动器,在 runner PATH 缺少 `hwpod` 时临时 fallback,并应记录为 runner image/package 摩擦点。正式 CLI 的默认行为是:
`tools/device-pod-cli.ts` 是 v0.2 正式 CLI 实现;HWLAB code-agent runner 内的唯一稳定短入口是 `hwpod`runner 镜像必须把 `hwpod` 放入 PATH;缺少 `hwpod` 时应判定为 runner image/package 错误并修复镜像或封装,不得改走 `/app/skills/device-pod-cli/scripts/device-pod-cli.mjs` 长路径。正式 CLI 的默认行为是:
- `profile list/show` 调用 cloud-api `/v1/device-pods``/status`,只显示服务端脱敏 profile 摘要和 `profileHash`
- `setup first-admin``admin device-pod upsert/grant` 只作为 cloud-api REST wrapper,用于首次空库 seed 或 admin profile/grant 管理;它们接受显式 `--profile-json``--device-pod-json`,不得读取本地 `.device-pod/*.json` 作为权威 profile。
+12 -1
View File
@@ -10,7 +10,7 @@
- 提供 WEB 等价的非视觉业务入口:登录鉴权、session 恢复、Device Pod 看板、Code Agent 对话、trace/result 轮询、logout 和工作台 live summary。
- 只走 Cloud Web 同源 API surface;默认 base URL 是 `http://74.48.78.17:19666`,也可通过 `--base-url``HWLAB_CLIENT_BASE_URL` 指向其他 Cloud Web 入口。
- Web/CLI 路径一致性优先于继续 Web 修复。Cloud Web 暴露 Code Agent、AgentRun、continuation、trace/result 或 provider 问题后,必须先能用 `bun tools/hwlab-cli/bin/hwlab-cli.ts client agent send/result/trace/inspect --base-url http://74.48.78.17:19666 ...` 对同一 Cloud Web base URL、同一 `/v1/agent/chat`、同一 `conversationId/sessionId/threadId/retryOf` 复现或解释,再继续修 Web 状态机。`19667` Cloud API 只用于显式 admin/setup/gateway 诊断,不得替代 WEB 同源路径验收。
- Web/CLI 路径一致性优先于继续 Web 修复。Cloud Web 暴露 Code Agent、AgentRun、continuation、steer、trace/result 或 provider 问题后,必须先能用 `bun tools/hwlab-cli/bin/hwlab-cli.ts client agent send/result/trace/inspect/steer --base-url http://74.48.78.17:19666 ...` 对同一 Cloud Web base URL、同一 `/v1/agent/chat*`、同一 `conversationId/sessionId/threadId/retryOf` 复现或解释,再继续修 Web 状态机。`19667` Cloud API 只用于显式 admin/setup/gateway 诊断,不得替代 WEB 同源路径验收。
- 从 Web trace 回放 Code Agent 问题时,优先用 `client agent send --from-trace <traceId>` 读取 Cloud Web 的 `/v1/agent/chat/inspect`,自动带回原 `conversationId/sessionId/threadId` 并把 `retryOf` 指向来源 trace;只有 inspect 缺失时才手动传 `--conversation-id``--session-id``--thread-id``--retry-of`。CLI 输出必须包含 replay 来源、inspect 状态和 redacted continuation 摘要。
- 默认业务子命令不直连 Postgres、Kubernetes Service、Secret、device-pod 内部 Service、gateway RPC 或本地 fixture;需要鉴权的请求使用 `/auth/*` 返回的 cookie 或显式 `--cookie`。唯一例外是 `client gateway` 诊断族:它允许显式 `--api-base-url` 指向 Cloud API,用于短连接观测 gateway session、单次 shell invoke 和 transport 压测;该入口只验证底层传输稳定性,不替代 Web 用户流程授权,也不发布镜像或常驻服务。
- Pod 内透传执行不放进 `hwlab-cli`;需要进入正在工作的 Code Agent/Cloud API pod 时,`hwlab-cli` 只查询并输出 UniDesk 标准 route,实际透传由 UniDesk `bun scripts/cli.ts ssh 'G14:k3s:hwlab-v02:pod:<pod>:<container>' ...` 完成。`pod:` 是 route 语法,`/` 只用于 pod 内文件系统路径。
@@ -24,7 +24,9 @@
- CLI 本地登录态必须支持 `--profile NAME` 隔离,同一 base URL 下不同 profile 写入 `.state/hwlab-cli/profiles/<base-url-hash>/<profile>.json`。切换到其他账号再切回原账号时,`client workbench restore/status` 必须从服务端账号 workspace 恢复之前的 `workspaceId``conversationId``sessionId``threadId``activeTraceId` 和 revision,而不是只依赖本地文件。
- `client workbench restore/status/watch/reset` 是账号 workspace 的非视觉入口:`restore/status` 对应 `GET /v1/workbench/workspace``watch` 对应 `/events?afterRevision=``reset --confirm` 对应服务端 reset。输出必须显示 workspace revision、selected conversation/session、active trace 和本地 state file,且不得保存 password、session token 原文以外的 Secret 值。
- `client agent send` 是 Cloud Web Code Agent composer 的非视觉等价入口。它必须支持 `--from-trace``--conversation-id``--session-id``--thread-id``--retry-of`,并在输出中返回 redacted continuation 摘要,证明本次 CLI 请求是否覆盖 Web 的继续会话路径。浏览器 issue 中已经给出 traceId 时,复现命令优先使用 `--from-trace <traceId>`,让 CLI 先走 `/v1/agent/chat/inspect` 读取 Web 上下文,再提交同源 `/v1/agent/chat`
- Code Agent continuation 的 thread 字段只有 `threadId` 一个标准名称。CLI 读取 inspect、`--from-trace` 回放、手动 `--thread-id` 提交和输出摘要都必须以该字段为唯一 thread identity;服务端响应也应保持同一字段口径。
- `client agent send` 默认先恢复账号 workspace,再向 `/v1/agent/chat` 发送 `workspaceId``expectedWorkspaceRevision`;服务端接受后 CLI 保存新的 workspace revision,终态轮询后再 PATCH workspace 清理终态 `activeTraceId`。只有显式 `--no-workspace` 才跳过这一默认恢复路径。
- `client agent steer <traceId>` 是运行中引导入口,必须调用 Cloud Web 同源 `POST /v1/agent/chat/steer`,把 steer 文本装配成 AgentRun `type=steer` command 作用到目标 trace 的 active turn。CLI 不手动穿内部 URL;验收使用当前 runtime namespace/lane 自动解析的 `19666` Web 入口,并通过原 trace 的 result/trace 观察 steer 是否被 runner 接收和应用。
- `client agent trace <traceId> --render web` 必须调用 Cloud Web trace row 的同一纯转换路径,输出 `render="web"`、renderer 标识、source event count、rendered row count、默认压制的 noise event count 和 row 摘要。浏览器 trace 展示错乱时,必须先用该 CLI 入口确认 Web 渲染转换是否已经乱序、重复、缺 final response、吞掉关键 row 或只显示泛化 tool call,再继续修浏览器 DOM/CSS。
- AgentRun v0.1 短连接 runner 已要求支持同 run/runner 多轮 command。CLI 仍应把 Web 提交的 `conversationId/sessionId/threadId` 原样送到 Cloud Web API,用于验证 adapter 是否在 runner lease 有效时复用同一个 AgentRun `runId` / `jobName` 并创建新 `commandId`;每轮都新建 runner 或重新 bundle 不是通过状态,trace 中的原因说明只能用于定位。
- `client harness``client harness-ops``client harness-opt` 吸收 G14 harness-ops 的短连接业务能力:health、submit、result、trace、wait 和 audit。
@@ -57,6 +59,7 @@
| `hwlab-cli client gateway pressure` | `POST Cloud API /v1/rpc/hardware.invoke.shell` | 显式 Cloud API 压测入口,真实验证大输出、长单行、stderr、timeout 和并发超容量不会造成黑洞。 |
| `hwlab-cli client agent send` | `GET /v1/agent/chat/inspect` + `POST /v1/agent/chat` + `GET /result/{trace}` | 以 short connection 提交 Code Agent 消息并轮询结果,默认输出 assistant 回复文本摘要;支持 `--from-trace` 自动复现 Web continuation,也支持手动 `--conversation-id``--session-id``--thread-id``--retry-of`。 |
| `hwlab-cli client agent trace TRACE [--render web]` | `GET /v1/agent/chat/trace/{trace}` | 回放 trace,默认输出状态、事件摘要和 assistant stream 文本;`--render web` 复用 Cloud Web trace row 转换,便于 CLI 复现 Web trace 渲染问题。 |
| `hwlab-cli client agent steer TRACE` | `POST /v1/agent/chat/steer` | 对目标 trace 的运行中 Code Agent turn 发送 steer 文本;默认短连接返回 accepted 和 steer command 摘要,后续观察原 trace 的 result/trace。 |
| `hwlab-cli client agent cancel TRACE` | `POST /v1/agent/chat/cancel` | 取消当前 Code Agent 请求。 |
| `hwlab-cli client harness submit` | `POST /v1/agent/chat` | G14 harness-ops 的短连接提交入口,默认 provider profile 为 `deepseek`,返回 trace/result URL`harness-ops``harness-opt` 是同义别名。 |
| `hwlab-cli client harness wait/result/trace` | `GET /v1/agent/chat/result/{trace}``GET /trace/{trace}` | 轮询或读取一次 Code Agent 结果和 trace;单次 wait 最长 60 秒。 |
@@ -86,6 +89,10 @@
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:对一个来自 Cloud Web 的失败 trace 运行 `client agent send --from-trace <traceId> --message "重试上一条" --provider-profile deepseek --no-wait`,确认 CLI 先访问 `/v1/agent/chat/inspect?traceId=<traceId>`,再提交 `/v1/agent/chat`,输出包含 `replay.source``continuation.replayedFromTrace``conversationId/sessionId/threadId/retryOf`,且不输出 cookie、token 或 secret 原文。
## T5.1
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:复测取消后追问场景时,`client agent send/result/trace --render web` 输出只允许出现标准 `threadId`,不允许出现历史 thread 别名字段;第二轮必须是新 `commandId` 且 trace row 不包含上一 command 的尾部 assistant/tool/terminal 文本。
## T3A
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:分别用 `--profile admin-a``--profile admin-a-second` 登录同一个账号,运行 `client workbench restore/status`,确认两个 profile 看到相同 `workspaceId` 和 revision;再用 `--profile other-user` 登录另一个账号,确认 workspace 不同;最后切回 `admin-a`,确认原 workspace、conversation/session/thread 仍能恢复。该验收必须使用真实 `http://74.48.78.17:19666`,不能 mock。
@@ -94,6 +101,10 @@
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:对一个来自 Cloud Web 的真实 trace 运行 `client agent trace <traceId> --render web --limit 80`,确认输出 `renderer=web/hwlab-cloud-web/app-trace:traceDisplayRows`、source event count、rendered row count 和 row 摘要;如果 Web trace 展示错乱,应先用该命令复现并定位到 Web row 转换还是浏览器 DOM/CSS 层。
## T3.3
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:先运行 `client agent send --message "执行一个会持续运行的任务,等待后续 steer" --provider-profile deepseek` 获得运行中 trace,再运行 `client agent steer <traceId> --message "请把最终回复包含 STEER_ACCEPTED 标记"`,最后用 `client agent result <traceId>``client agent trace <traceId> --render web` 确认同一 target trace 出现 AgentRun steer command 事件且最终回复或 trace 可见 steer 处理结果。该验收必须使用真实 `http://74.48.78.17:19666` 或 runtime namespace 自动解析出的同源 Web 入口,不能 mock,也不能用自动交互脚本。
## T4
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `client request GET /v1/access/status``client request GET /v1/diagnostics/gate``client rpc system.health`,确认它们全部走 `19666` Cloud Web 同源 API,返回 JSON route/httpStatus/body,且 `client request` 传入绝对 URL 会被拒绝。
+10 -4
View File
@@ -16,7 +16,9 @@
- `internal/cloud/access-control.ts` 也是账号 workspace authority`account_workspaces` 记录同一账号的 Workbench 当前 workspace、selected conversation/session、active trace、provider profile 和 revision。
- `internal/db/runtime-store.ts``internal/cloud/db-contract.ts` 负责 Postgres runtime store 与 readiness 分层。
- `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。
- Cloud API 通过 AgentRun v0.1 `runner-jobs.transientEnv` 传递本次 Code Agent turn 的短期上下文,例如 `HWLAB_RUNTIME_*``HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME` 和 device-pod session token。`transientEnv` 不设固定 8 项上限,新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value,并继续禁止承载 GitHub token、provider key、长期 SSH key 或其他可复用 credential
- AgentRun v0.1 接入只使用标准 `threadId` 路径:`POST /v1/agent/chat` 收到的 `conversationId/sessionId/threadId` 必须写入 AgentRun command `payload.threadId``SessionRef.threadId`;协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity
- AgentRun run 级 events 写回 HWLAB trace 时必须按当前 `commandId` 归属过滤;同一 run 的旧 command 尾部事件不能混入后续 command trace。取消、失败或 blocked 轮次如果已有 assistant/tool 可读上下文,必须以脱敏、限长的 partial context 写入 conversation facts,供后续 `inspect`/`--from-trace` 和同一 thread 的下一轮使用。
- Cloud API 通过 AgentRun v0.1 `runner-jobs.transientEnv` 传递本次 Code Agent turn 的短期上下文,例如 `HWLAB_RUNTIME_*``HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME` 和 device-pod session token。`transientEnv` 不设固定 8 项上限,新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value,并继续禁止承载 GitHub token、provider key、长期 SSH key 或其他可复用 credential;文档、日志和 trace 只允许保留脱敏后的 name、来源或摘要,不打印 Secret 值。
- 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。
- `hwlab-code-agent-workspace` PVC 挂载到 `/workspace/hwlab`,用于长会话 workspace;它是 cloud-api 运行资源,不是独立用户入口。
@@ -38,7 +40,7 @@
| `GET /v1/diagnostics/gate``GET /v1/live-builds` | 诊断和 live build inventory。 |
| `GET /v1/gateway/sessions``POST /v1/gateway/poll``POST /v1/gateway/result` | gateway 主动出站注册、取任务和回传结果。 |
| `POST /v1/internal/device-pod/gateway-dispatch` | 仅接受 `hwlab-device-pod` 内部服务凭据,用于把 executor job dispatch 到 gateway poll/result;普通用户和 Code Agent 不可调用。 |
| `POST /v1/agent/chat``GET /v1/agent/chat/result/{traceId}``GET /v1/agent/chat/trace/{traceId}``POST /v1/agent/chat/cancel` | Code Agent 短连接提交、轮询、trace 和取消。 |
| `POST /v1/agent/chat``POST /v1/agent/chat/steer``GET /v1/agent/chat/result/{traceId}``GET /v1/agent/chat/trace/{traceId}``POST /v1/agent/chat/cancel` | Code Agent 短连接提交、运行中 steer、轮询、trace 和取消。 |
用户、权限、device-pod 管理 API 的最终规格见 [spec-user-access.md](spec-user-access.md) 和 [spec-device-pod.md](spec-device-pod.md)。
@@ -50,7 +52,11 @@
## T2
阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:使用短连接 `POST /v1/agent/chat` 提交一次对话,再轮询 `/v1/agent/chat/result/{traceId}`,只有 `status=completed` 且 assistant reply 非空才算 Code Agent 通过。
阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:使用短连接 `POST /v1/agent/chat` 提交一次对话,再轮询 `/v1/agent/chat/result/{traceId}`,只有 `status=completed` 且 assistant reply 非空才算 Code Agent 通过;对运行中请求使用 `POST /v1/agent/chat/steer` 时,响应必须包含 target trace、steerTraceId、AgentRun runId/targetCommandId/steerCommandId 和继续观察原 trace 的 result/trace URL
## T2.1
阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:在同一 `conversationId/sessionId/threadId` 中先发起一轮会产生可见 assistant/tool 进展的 Code Agent 请求并取消,再发送“回答一下刚才调查结果”这类后续消息;第二轮必须继续使用同一个标准 `threadId`,且 trace/result 不能混入上一 command 的尾部事件。
## T3
@@ -61,7 +67,7 @@
| 规格项 | 状态 | 说明 |
| --- | --- | --- |
| health/readiness | 已实现 | `/health/live` 汇总 DB、runtime、Code Agent 和 blocker。 |
| Code Agent 短连接 submit/result/trace/cancel | 已实现 | repo-owned Codex stdio 和 provider profile 已接入。 |
| Code Agent 短连接 submit/steer/result/trace/cancel | 已实现 | repo-owned Codex stdio、运行中 steer 和 provider profile 已接入。 |
| Postgres durable runtime | 已实现 | 通过 v02 独立 DB SecretRef 和 migration ledger 判定。 |
| gateway outbound poll/result | 已实现 | 支持 gateway 主动轮询和 `hardware.invoke.shell` 分发。 |
| device-pod 正式权限/profile/job | 部分实现 | profile/grant/list/status/job 持久化在 cloud-api;用户态 probe GET 已收敛为只读 job;已提供内部 gateway dispatch route 供 `hwlab-device-pod` executor 下发到 device-host-cli,无在线 gateway/device-host-cli 时返回 blocker。 |
+9 -1
View File
@@ -12,6 +12,8 @@
- Cloud Web trace 展示与 `hwlab-cli client agent trace --render web` 必须共享同一套 trace row 纯转换路径。Web 发生 row 顺序错乱、final response 缺失、assistant 消息被吞、tool call 只显示泛化占位或噪声事件淹没时,先用 CLI 输出同一渲染 row 摘要和 noise event count 复现;CLI 可复现说明是 trace row 转换问题,CLI 不可复现再进入 DOM/CSS/滚动状态调查。默认展示应压制 AgentRun backend echo、token/rate-limit/status/terminal echo 等低价值事件,但原始 trace JSON 仍必须保留用于 `--full`/下载排障。
- Code Agent result `completed` 只有在同时包含真实 provider/model/trace/conversation 元数据、`providerTrace` 和可展示的 final assistant response 时,才能被 Web 标记为真实完成;`provider=agentrun-v01` 只是执行基础设施标识,不得替代上游 provider/model,也不得把 SOURCE、fixture、echo、mock 或 stub 当成 DEV-LIVE 完成。
- 同一 conversation/session 的后续用户消息必须在 AgentRun runner lease 有效时复用已存在的 AgentRun run/runner 继续新 command/turn;只有 runner 不可用、已过期或协议明确要求新 runner 时才重新 bundle 和启动 runner。每条消息都重新 bundle/runner 属于 v0.2 AgentRun 接入缺口,不能只靠 trace 显示原因当成已完成。
- AgentRun 会话连续性只有一个标准路径:Cloud Web/CLI 提交的 `threadId` 必须经 Cloud API adapter 写入 AgentRun command `payload.threadId``SessionRef.threadId`。前端、CLI、API 和 AgentRun 的协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity。
- 同一 AgentRun run 复用多条 command 时,Web trace 展示只显示当前 command 归属事件和必要 run 级状态;旧 command 的 assistant/tool/terminal 尾部不能堆到新 command 末尾。取消轮次的可读进展必须作为脱敏 partial context 进入 conversation facts 和标准 thread,而不是靠旧 trace 尾部串线让后续轮次“碰巧看到”。
## 内部架构
@@ -29,7 +31,7 @@
| `GET /help` | 返回可用 route 摘要。 |
| `GET /v1``GET /v1/...` | 同源代理到 `hwlab-cloud-api`;公开的 Code Agent result/trace 轮询按 route policy 处理。 |
| `GET/PATCH /v1/workbench/workspace...` | 同源代理到 cloud-api 的账号 workspace authority,用于 Web/CLI 共享工作区和 revision 冲突保护。 |
| `POST /v1/agent/chat``POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口。 |
| `POST /v1/agent/chat``POST /v1/agent/chat/steer``POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口steer 必须走同一个 `19666` Web path,由 cloud-api/AgentRun 判断目标 turn 是否可接收。 |
| `POST /v1/device-pods/...` | 受控同源代理到 cloud-api 的 Device Pod job/操作入口;只要 Cloud API 已提供对应能力,Cloud Web 不能只代理 list/status 而让 job POST 在 `19666` 返回 404。 |
| `POST /v1/m3/io``POST /json-rpc` | 同源代理到受控 API;不能绕过 cloud-api 直连硬件服务。 |
@@ -51,6 +53,8 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验,
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:从同源 `19666` 提交 Code Agent 短连接请求并轮询 result,确认请求经 cloud-web proxy 到 `hwlab-cloud-api`,且 trace 可回放。
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:对运行中 Code Agent trace 调用 `hwlab-cli client agent steer <traceId> --message ...`,确认请求走 Cloud Web 同源 `POST /v1/agent/chat/steer`Web 层不能返回 `serviceId=hwlab-cloud-web` 的 404,目标不存在、非运行中或 runner 拒绝时必须透传 cloud-api/AgentRun 的结构化业务状态。
## T2.1
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:对浏览器暴露的 Code Agent trace 运行 `hwlab-cli client agent trace <traceId> --render web --limit 80`,确认 CLI 与 Web 使用同一 trace row 转换;若 final response 缺失、assistant row 顺序错乱或噪声事件过多,先用 CLI 固定复现再修实现。
@@ -59,6 +63,10 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验,
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:在同一 conversation/session 连续发送两条 Code Agent 消息,确认第二条复用第一条的 AgentRun `runId` 和 runner `jobName`、生成新的 `commandId`,且不重新 materialize bundle/启动新 runnerresult completed 必须包含真实 provider/model/`providerTrace`/trace/conversation 和 final assistant response。复用失败原因只能作为诊断,不作为本测试通过条件。
## T2.3
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:对一轮已取消但取消前存在 assistant/tool 进展的 trace 发送同一 conversation/session/thread 后续问题,确认请求和 result 只携带标准 `threadId`,不出现历史 thread 别名字段;`--render web` 输出不得把上一 command 的尾部 assistant/tool/terminal row 堆到新 command 末尾。
## T3
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:打开 Workbench device-pod 面板,确认 status/freshness/blocker 显示来自 `/v1/device-pods`,未登录或未授权时必须显示认证/授权 blocker,不得把 fixture 或 blocked fallback 写成真实硬件 DEV-LIVE。
+82 -2
View File
@@ -95,9 +95,9 @@ test("workbench workspace is account-scoped, persistent, and gates active agent
projectId: "prj_device_pod_workbench",
sessionId: "ses_issue655_shared",
threadId: "thread-issue-655",
sessionStatus: "active",
sessionStatus: "running",
lastTraceId: "trc_issue655_previous",
messages: [{ role: "user", text: "persist me", traceId: "trc_issue655_previous" }]
messages: [{ role: "agent", text: "persist me", status: "running", traceId: "trc_issue655_active" }]
}, aliceLogin.cookie);
assert.equal(conversation.status, 200);
assert.equal(conversation.body.conversation.conversationId, "cnv_issue655_shared");
@@ -467,6 +467,86 @@ test("workbench workspace releases orphan cancel-blocked active trace on new tur
}
});
test("workbench workspace permits continuation when stale active trace has idle active conversation", async () => {
const workspaceDir = await mkdtemp("/tmp/hwlab-stale-active-idle-");
const server = createCloudApiServer({
env: {
PATH: process.env.PATH,
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspaceDir,
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
OPENAI_API_KEY: "test-openai-key-material"
},
codexStdioManager: {
describe: () => ({ available: true, ready: true, workspace: workspaceDir, sandbox: "danger-full-access" }),
probe: async () => ({ available: true, ready: true, workspace: workspaceDir, sandbox: "danger-full-access" }),
chat: async (params = {}) => ({
status: "completed",
traceId: params.traceId,
conversationId: params.conversationId,
sessionId: params.sessionId,
threadId: params.threadId,
reply: { role: "assistant", content: "ok" },
runnerTrace: { traceId: params.traceId, status: "completed", events: [], eventCount: 0 },
session: { sessionId: params.sessionId, conversationId: params.conversationId, threadId: params.threadId, status: "idle" }
}),
cancel() {},
reapIdle() {}
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", adminLogin.cookie);
const conversation = await putJson(port, "/v1/agent/conversations/cnv_stale_active_idle", {
projectId: "prj_device_pod_workbench",
sessionId: "ses_stale_active_idle",
threadId: "thread-stale-active-idle",
status: "active",
sessionStatus: "active",
lastTraceId: "trc_stale_active_idle_missing",
messages: [{ role: "agent", text: "previous completed turn", status: "completed", traceId: "trc_stale_active_idle_missing" }]
}, adminLogin.cookie);
assert.equal(conversation.status, 200);
const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
expectedRevision: 1,
selectedConversationId: "cnv_stale_active_idle",
selectedAgentSessionId: "ses_stale_active_idle",
activeTraceId: "trc_stale_active_idle_missing",
sessionStatus: "running",
updatedByClient: "test-suite"
}, adminLogin.cookie);
assert.equal(update.status, 200);
const next = await postJson(port, "/v1/agent/chat", {
message: "new turn after stale active idle trace",
traceId: "trc_stale_active_idle_next",
conversationId: "cnv_stale_active_idle",
sessionId: "ses_stale_active_idle",
threadId: "thread-stale-active-idle",
workspaceId: workspace.body.workspace.workspaceId,
expectedWorkspaceRevision: 2,
shortConnection: true
}, adminLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_stale_active_idle_next" });
assert.equal(next.status, 202);
assert.equal(next.body.traceId, "trc_stale_active_idle_next");
assert.equal(next.body.conversationId, "cnv_stale_active_idle");
assert.equal(next.body.sessionId, "ses_stale_active_idle");
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", adminLogin.cookie);
assert.equal(restored.body.workspace.activeTraceId, "trc_stale_active_idle_next");
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("workbench workspace status clears completed AgentRun active trace on read", async () => {
const agentRunCalls = [];
const agentSessions = new Map();
+173 -21
View File
@@ -33,6 +33,9 @@ const AGENTRUN_BACKENDS = Object.freeze(["codex", "deepseek", "minimax-m3"]);
const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-reuse-runner-thread";
const SESSION_POLICY_RUN_LOCAL = "hwlab-agentrun-v01-session-runner-reuse";
const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "blocked", "cancelled", "canceled"]);
const HWLAB_RESOURCE_TOOL_ALIASES = Object.freeze([
Object.freeze({ name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" })
]);
export function codeAgentAgentRunAdapterEnabled(env = process.env) {
const value = String(env.HWLAB_CODE_AGENT_ADAPTER ?? env.HWLAB_CODE_AGENT_PROVIDER ?? "").trim().toLowerCase();
@@ -61,7 +64,6 @@ export function initialAgentRunChatResult({ params = {}, options = {}, traceId }
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
sessionMode: CODEX_STDIO_SESSION_MODE,
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
requestedThreadId: safeOpaqueId(params.threadId) || null,
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
agentRun: {
adapter: ADAPTER_ID,
@@ -148,7 +150,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI
}
}
const sessionId = agentRunSessionIdForParams(params, traceId);
const sessionId = scopedAgentRunSessionIdForParams(params, traceId, backendProfile);
const runInput = buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId });
const run = await agentRunJson(fetchImpl, managerUrl, "/api/v1/runs", {
method: "POST",
@@ -312,6 +314,87 @@ export async function cancelAgentRunChatTurn({ traceId, currentResult = null, op
return payload;
}
export async function steerAgentRunChatTurn({ traceId, currentResult = null, params = {}, options = {}, traceStore = defaultCodeAgentTraceStore }) {
const mapped = currentResult ?? await loadPersistedAgentRunResult(traceId, options);
if (!safeTraceId(traceId) || !mapped?.agentRun?.runId || !mapped?.agentRun?.commandId) return null;
const message = firstNonEmpty(params.message, params.prompt, params.text);
if (!message) {
throw Object.assign(new Error("steer command requires non-empty message text"), { code: "steer_message_missing", statusCode: 400 });
}
const env = options.env ?? process.env;
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl);
const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000);
const steerTraceId = safeTraceId(params.steerTraceId ?? params.steerId) || `trc_steer_${randomUUID().replace(/-/gu, "")}`;
traceStore.append(traceId, {
type: "backend",
status: "running",
label: "agentrun:steer:accepted",
message: "HWLAB accepted an in-flight steer request and will create an AgentRun type=steer command on the existing run.",
runId: mapped.agentRun.runId,
commandId: mapped.agentRun.commandId,
steerTraceId,
waitingFor: "agentrun-steer-command-create",
valuesPrinted: false
});
const commandInput = buildAgentRunSteerCommandInput({ params: { ...params, message }, traceId, steerTraceId, mapped });
const command = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/commands`, {
method: "POST",
body: commandInput,
timeoutMs
});
const steerCommandId = requiredString(command?.id, "command.id");
const now = nowIso(options.now);
const agentRun = {
...mapped.agentRun,
lastSteerCommandId: steerCommandId,
lastSteerTraceId: steerTraceId,
updatedAt: now,
valuesPrinted: false
};
traceStore.append(traceId, {
type: "backend",
status: "running",
label: "agentrun:steer:command-created",
message: `AgentRun steer command ${steerCommandId} created on run ${mapped.agentRun.runId}; runner will apply it if the target Codex turn is still active.`,
runId: mapped.agentRun.runId,
commandId: mapped.agentRun.commandId,
steerCommandId,
targetCommandId: mapped.agentRun.commandId,
steerTraceId,
waitingFor: "agentrun-steer-apply",
valuesPrinted: false
});
const payload = {
ok: true,
accepted: true,
status: "running",
shortConnection: true,
controlSemantics: "steer-active-turn-and-poll-target-trace",
route: "/v1/agent/chat/steer",
traceId,
targetTraceId: traceId,
steerTraceId,
conversationId: safeConversationId(mapped.conversationId ?? params.conversationId) || null,
sessionId: safeSessionId(mapped.sessionId ?? params.sessionId) || null,
threadId: safeOpaqueId(mapped.threadId ?? params.threadId) || null,
resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`,
traceUrl: `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`,
agentRun: {
...agentRun,
steerCommandId,
targetCommandId: mapped.agentRun.commandId,
steerCommandState: command.state ?? null,
valuesPrinted: false
},
runnerTrace: traceStore.snapshot(traceId, agentRunTraceMeta({}, {})),
updatedAt: now,
valuesPrinted: false
};
options.codeAgentChatResults?.set?.(traceId, { ...mapped, agentRun, runnerTrace: payload.runnerTrace, updatedAt: now });
return payload;
}
export async function loadPersistedAgentRunResult(traceId, options = {}) {
if (!safeTraceId(traceId) || typeof options.accessController?.getAgentSessionByTraceId !== "function") return null;
const session = await options.accessController.getAgentSessionByTraceId(traceId);
@@ -362,7 +445,7 @@ export function agentRunSessionEvidence(payload = {}) {
lastSeq: payload.agentRun.lastSeq ?? 0,
providerId: payload.agentRun.providerId ?? DEFAULT_PROVIDER_ID,
reuseEligible: payload.agentRun.reuseEligible ?? false,
sessionId: payload.sessionId ?? payload.agentRun.sessionId ?? null,
sessionId: payload.agentRun.sessionId ?? payload.sessionId ?? null,
conversationId: payload.conversationId ?? payload.agentRun.conversationId ?? null,
threadId: payload.threadId ?? payload.agentRun.threadId ?? null,
providerTrace: payload.providerTrace ?? payload.agentRun.providerTrace ?? null,
@@ -373,13 +456,15 @@ export function agentRunSessionEvidence(payload = {}) {
function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId }) {
const commitId = fullSourceCommit(env);
const threadId = safeOpaqueId(params.threadId);
const resourceBundleRef = commitId
? {
kind: "git",
repoUrl: resolveAgentRunRepoUrl(env),
commitId,
submodules: false,
lfs: false
lfs: false,
toolAliases: HWLAB_RESOURCE_TOOL_ALIASES
}
: null;
return {
@@ -394,16 +479,18 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses
valuesPrinted: false
},
sessionRef: {
sessionId: sessionId ?? agentRunSessionIdForParams(params, traceId),
sessionId: sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile),
...(safeConversationId(params.conversationId) ? { conversationId: safeConversationId(params.conversationId) } : {}),
...(threadId ? { threadId } : {}),
metadata: {
adapter: ADAPTER_ID,
hwlabTraceId: traceId,
hwlabApi: "/v1/agent/chat",
hwlabSessionId: safeSessionId(params.sessionId) || null,
requestedThreadId: safeOpaqueId(params.threadId) || null,
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
sessionPolicy: SESSION_POLICY_RUN_LOCAL,
agentRunSessionProfile: backendProfile,
agentRunSessionPolicy: "backend-profile-scoped",
valuesPrinted: false
}
},
@@ -432,6 +519,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses
function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }) {
const prompt = String(params.message ?? params.prompt ?? "").trim();
const threadId = safeOpaqueId(params.threadId);
return {
type: "turn",
payload: {
@@ -439,10 +527,9 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId
message: prompt,
traceId,
conversationId: safeConversationId(params.conversationId) || null,
sessionId: sessionId ?? agentRunSessionIdForParams(params, traceId),
sessionId: sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile),
hwlabSessionId: safeSessionId(params.sessionId) || null,
threadId: null,
requestedThreadId: safeOpaqueId(params.threadId) || null,
threadId: threadId || null,
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
sessionPolicy: SESSION_POLICY_RUN_LOCAL,
providerProfile: backendProfile,
@@ -453,6 +540,28 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId
};
}
function buildAgentRunSteerCommandInput({ params, traceId, steerTraceId, mapped }) {
const prompt = String(params.message ?? params.prompt ?? params.text ?? "").trim();
return {
type: "steer",
payload: {
prompt,
message: prompt,
text: prompt,
traceId: steerTraceId,
targetTraceId: traceId,
targetCommandId: mapped.agentRun.commandId,
conversationId: safeConversationId(mapped.conversationId ?? params.conversationId) || null,
sessionId: mapped.agentRun.sessionId ?? safeSessionId(mapped.sessionId ?? params.sessionId) ?? null,
hwlabSessionId: safeSessionId(mapped.sessionId ?? params.sessionId) || null,
threadId: safeOpaqueId(mapped.threadId ?? params.threadId) || null,
source: "hwlab-cloud-api",
valuesPrinted: false
},
idempotencyKey: steerTraceId
};
}
function buildAgentRunRunnerJobInput({ env, traceId, commandId }) {
const transientEnv = buildAgentRunTransientEnv(env);
return {
@@ -487,18 +596,18 @@ function buildAgentRunTransientEnv(env = process.env) {
}
async function resolveReusableAgentRun({ params = {}, options = {}, env = process.env, managerUrl, fetchImpl, timeoutMs, backendProfile, traceId, traceStore }) {
const sessionId = agentRunSessionIdForParams(params, traceId);
if (!safeSessionId(sessionId) || sessionId === agentRunSessionId(traceId) || typeof options.accessController?.getAgentSession !== "function") {
const hwlabSessionId = hwlabSessionIdForParams(params, traceId);
if (!safeSessionId(hwlabSessionId) || hwlabSessionId === agentRunSessionId(traceId) || typeof options.accessController?.getAgentSession !== "function") {
return null;
}
const session = await options.accessController.getAgentSession(sessionId);
const session = await options.accessController.getAgentSession(hwlabSessionId);
if (!canReuseAgentRunSessionForOwner(session, params, options)) {
traceStore.append(traceId, {
type: "backend",
status: "running",
label: "agentrun:run:reuse-owner-skipped",
message: "Stored AgentRun session is not visible to the current actor; a new runner Job will be created for this request.",
sessionId,
sessionId: hwlabSessionId,
waitingFor: "agentrun-run-create",
valuesPrinted: false
});
@@ -511,7 +620,7 @@ async function resolveReusableAgentRun({ params = {}, options = {}, env = proces
status: "running",
label: "agentrun:run:reuse-skipped",
message: "No reusable AgentRun run was found for this HWLAB session; a new runner Job will be created.",
sessionId,
sessionId: hwlabSessionId,
waitingFor: "agentrun-run-create",
valuesPrinted: false
});
@@ -531,7 +640,7 @@ async function resolveReusableAgentRun({ params = {}, options = {}, env = proces
message: `Stored AgentRun run ${mapping.runId} could not be read; a new runner Job will be created.`,
errorCode: error?.code ?? "agentrun_run_lookup_failed",
runId: mapping.runId,
sessionId,
sessionId: hwlabSessionId,
waitingFor: "agentrun-run-create",
valuesPrinted: false
});
@@ -545,7 +654,7 @@ async function resolveReusableAgentRun({ params = {}, options = {}, env = proces
label: "agentrun:run:reuse-blocked",
message: `Stored AgentRun run ${mapping.runId} cannot be reused: ${reason}; a new runner Job will be created.`,
runId: mapping.runId,
sessionId,
sessionId: hwlabSessionId,
waitingFor: "agentrun-run-create",
valuesPrinted: false
});
@@ -555,17 +664,17 @@ async function resolveReusableAgentRun({ params = {}, options = {}, env = proces
type: "backend",
status: "running",
label: "agentrun:run:reused",
message: `AgentRun run ${mapping.runId} is reused for HWLAB session ${sessionId}; runner lease is active and no new bundle will be requested for this turn.`,
message: `AgentRun run ${mapping.runId} is reused for HWLAB session ${hwlabSessionId}; runner lease is active and no new bundle will be requested for this turn.`,
runId: mapping.runId,
commandId: mapping.commandId ?? null,
runnerId: mapping.runnerId ?? null,
jobName: mapping.jobName ?? null,
namespace: mapping.namespace ?? null,
sessionId,
sessionId: hwlabSessionId,
waitingFor: "agentrun-command-create",
valuesPrinted: false
});
return { sessionId, session, mapping, run };
return { sessionId: mapping.sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile), hwlabSessionId, session, mapping, run };
}
function isReusableAgentRunMapping(mapping, backendProfile) {
@@ -728,6 +837,7 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId })
});
const code = canceled ? "agentrun_canceled" : result?.failureKind ?? (terminalStatus === "blocked" ? "agentrun_blocked" : "agentrun_failed");
const message = result?.failureMessage ?? result?.blocker?.message ?? (canceled ? "AgentRun command was canceled" : "AgentRun command failed");
const partialContext = partialAgentRunContext(runnerTrace);
return {
...base,
status: canceled ? "canceled" : "failed",
@@ -736,7 +846,7 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId })
session: agentRunSessionSummary(base, canceled ? "canceled" : "failed"),
sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true),
runner: agentRunRunnerSummary(base.agentRun),
runnerTrace,
runnerTrace: partialContext ? { ...runnerTrace, partialContext } : runnerTrace,
toolCalls: agentRunToolCalls(result, canceled ? "canceled" : "failed"),
skills: { status: "delegated", provider: ADAPTER_ID, count: 0, items: [], valuesPrinted: false },
providerTrace,
@@ -762,6 +872,28 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId })
toolName: "agentrun.manual-dispatch"
},
agentRun: { ...base.agentRun, terminalStatus, completed: false, providerTrace, valuesPrinted: false },
...(partialContext ? { partialContext } : {}),
valuesPrinted: false
};
}
function partialAgentRunContext(runnerTrace = {}) {
const events = Array.isArray(runnerTrace.events) ? runnerTrace.events : [];
const assistantMessages = events
.filter((event) => event?.type === "assistant" && String(event.text ?? event.message ?? "").trim())
.map((event) => String(event.text ?? event.message).trim())
.slice(-4);
const toolEvidence = events
.filter((event) => event?.type === "tool_call" && String(event.status ?? "") === "completed")
.map((event) => [event.toolName ?? event.label ?? "tool", event.command ?? event.outputSummary ?? event.stdoutSummary ?? event.message ?? ""].filter(Boolean).join(": "))
.filter(Boolean)
.slice(-6);
if (assistantMessages.length === 0 && toolEvidence.length === 0) return null;
return {
status: "partial-before-terminal",
summary: "AgentRun terminal 前已有可延续的 assistant/tool 上下文;后续同 conversation/session/thread 轮次必须能继续使用。",
assistantMessages,
toolEvidence,
valuesPrinted: false
};
}
@@ -789,11 +921,20 @@ function decorateAgentRunRunningResult({ base, mapping, traceStore, traceId }) {
function appendAgentRunEventsToTrace(traceStore, traceId, events, mapping = {}) {
for (const event of events) {
if (isForeignAgentRunCommandEvent(event, mapping)) continue;
const normalized = mapAgentRunEvent(event, mapping);
if (normalized) traceStore.append(traceId, normalized, agentRunTraceMeta({}, {}));
}
}
function isForeignAgentRunCommandEvent(event, mapping = {}) {
const currentCommandId = typeof mapping.commandId === "string" ? mapping.commandId : "";
if (!currentCommandId) return false;
const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
const eventCommandId = typeof payload.commandId === "string" ? payload.commandId : "";
return Boolean(eventCommandId && eventCommandId !== currentCommandId);
}
function mapAgentRunEvent(event, mapping = {}) {
if (!event || typeof event !== "object") return null;
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
@@ -1010,10 +1151,21 @@ function agentRunResultRefs(result = {}) {
return refs;
}
function agentRunSessionIdForParams(params = {}, traceId) {
function hwlabSessionIdForParams(params = {}, traceId) {
return safeSessionId(params.sessionId) || agentRunSessionId(traceId);
}
function scopedAgentRunSessionIdForParams(params = {}, traceId, backendProfile) {
const baseSessionId = hwlabSessionIdForParams(params, traceId);
const profile = agentRunSessionProfileToken(backendProfile);
const base = String(baseSessionId).replace(/^ses_/u, "").replace(/[^A-Za-z0-9_]+/gu, "_").replace(/^_+|_+$/gu, "") || "session";
return `ses_agentrun_${profile}_${base}`;
}
function agentRunSessionProfileToken(backendProfile) {
return String(backendProfile ?? "deepseek").trim().toLowerCase().replace(/[^a-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "") || "default";
}
function agentRunSessionId(traceId) {
return `ses_agentrun_${String(safeTraceId(traceId) || `trc_${randomUUID()}`).slice(4)}`;
}
+12 -1
View File
@@ -245,7 +245,10 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations;
if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility;
if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate;
if (error.conversationFacts !== undefined) {
const factPayload = shouldRecordErrorConversationFact(payload) ? attachConversationFacts(decorateChatSessionLifecycle(payload), sessionRegistry, { now: options.now }) : null;
if (factPayload?.conversationFacts) {
payload.conversationFacts = factPayload.conversationFacts;
} else if (error.conversationFacts !== undefined) {
payload.conversationFacts = error.conversationFacts;
} else if (typeof sessionRegistry.getConversationFacts === "function") {
payload.conversationFacts = sessionRegistry.getConversationFacts(conversationId);
@@ -263,6 +266,14 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
}
}
function shouldRecordErrorConversationFact(payload = {}) {
if (!payload || typeof payload !== "object") return false;
if (payload.status === "running" || payload.status === "completed") return false;
if (payload.partialContext) return true;
if (payload.runnerTrace?.partialContext) return true;
return Boolean(payload.runnerTrace?.traceId && payload.sessionId && payload.conversationId);
}
function completedRunnerPayload({ base, runnerResult, messageId, now, sessionRegistry }) {
const completedAt = nowIso(now);
const blocker = structuredCompletionBlocker(runnerResult, {
@@ -477,11 +477,31 @@ function sanitizeConversationFact(fact, { conversationId, timestamp, maxFactTool
toolCalls,
skills,
runnerTrace: sanitizeRunnerTrace(runnerTrace),
partialContext: sanitizePartialContext(fact.partialContext ?? fact.partialConversationContext ?? runnerTrace?.partialContext),
valuesRedacted: true,
secretMaterialStored: false
};
}
function sanitizePartialContext(value) {
if (!value || typeof value !== "object") return null;
const assistantMessages = Array.isArray(value.assistantMessages)
? value.assistantMessages.map((item) => safeFactText(item, 240)).filter(Boolean).slice(-4)
: [];
const toolEvidence = Array.isArray(value.toolEvidence)
? value.toolEvidence.map((item) => safeFactText(item, 200)).filter(Boolean).slice(-6)
: [];
const summary = safeFactText(value.summary, 400);
if (!summary && assistantMessages.length === 0 && toolEvidence.length === 0) return null;
return {
status: safeFactText(value.status ?? "partial", 40) ?? "partial",
summary,
assistantMessages,
toolEvidence,
valuesRedacted: true
};
}
function sanitizeToolCalls(toolCalls, limit) {
const items = Array.isArray(toolCalls)
? toolCalls.slice(0, limit).map((toolCall) => ({
@@ -544,6 +564,7 @@ function sanitizeRunnerTrace(runnerTrace) {
: [],
route: safeFactText(runnerTrace.route, 120),
status: safeFactText(runnerTrace.status, 80),
partialContext: sanitizePartialContext(runnerTrace.partialContext),
outputTruncated: runnerTrace.outputTruncated === true,
valuesPrinted: false
};
+6 -6
View File
@@ -204,7 +204,7 @@ export const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [
"Use the provided workspace and repo-owned Codex stdio session only.",
"Do not read or print secrets, tokens, kubeconfig files, DB URLs, private keys, or raw environment values.",
"For hardware, gateway, box-simu, patch-panel, DAP, PWM, Keil, serial, and Windows skill requests, execute the requested work through the repo-owned Codex stdio session with the available tran wrapper, skill CLI, or project tool that actually reaches the target.",
"For any request that mentions device-pod, device-pod-cli, device-host-cli, a device-pod profile, device-pod-71-freq, device-pod-71-00075-11, D601-F103-V2, or a registered device-pod Keil build/download/UART/debug-probe operation, first use the HWLAB internal skill named device-pod-cli. The canonical skill location is /app/skills/device-pod-cli/SKILL.md; read that manifest and run hwpod for the operation. Use node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs only as a compatibility fallback when hwpod is absent. After selecting the target pod or resuming context, run bootsharp --pod-id <devicePodId> before edits, build, or download.",
"For any request that mentions device-pod, device-pod-cli, device-host-cli, a device-pod profile, device-pod-71-freq, device-pod-71-00075-11, D601-F103-V2, or a registered device-pod Keil build/download/UART/debug-probe operation, first use the HWLAB internal skill named device-pod-cli. The canonical skill location is /app/skills/device-pod-cli/SKILL.md; read that manifest and run hwpod for the operation. hwpod is the only standard runner entry; if it is absent, report a runner image/package error instead of invoking a long-path launcher. After selecting the target pod or resuming context, run bootsharp --pod-id <devicePodId> before edits, build, or download.",
"Do not pass --api-base-url, --api-url, --cloud-api-url, --base-url, or session tokens to hwpod in HWLAB runners. WEB and AgentRun assemble the current runtime endpoint and credential; hwpod must auto-locate from that assembled environment.",
"When a matching device-pod profile exists, do not bypass device-pod-cli with direct hwlab-gateway-tran.mjs, Windows Keil skills, serial-monitor skills, keil-cli.py, or ad hoc path discovery. Only drop to tran or Windows-side skills when the device-pod-cli skill explicitly says to bootstrap, install, or repair the lower layer.",
"For registered PC gateway Windows command or skill requests, use the preloaded tran wrapper: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work <cmd|ps|upload|download> [options] -- <args>. The locator before ':' is the gateway session and the path after ':' is the Windows workspace, with /f/work and f:/work both mapping to F:\\work.",
@@ -1143,7 +1143,7 @@ export function createCodexStdioSessionManager(options = {}) {
const timestampMs = Date.parse(timestamp);
const conversationId = requiredId(params.conversationId, "cnv");
const requestedSessionId = optionalId(params.requestedSessionId);
const requestedThreadId = optionalId(params.threadId);
const threadId = optionalId(params.threadId);
const mappedSessionId = conversations.get(conversationId) ?? null;
if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) {
@@ -1162,7 +1162,7 @@ export function createCodexStdioSessionManager(options = {}) {
let reused = Boolean(session);
if (session && sessionExpired(session, timestampMs)) {
const resumeThreadId = requestedThreadId ?? optionalId(session.threadId);
const resumeThreadId = threadId ?? optionalId(session.threadId);
if (["idle", "expired"].includes(session.status) && resumeThreadId) {
session.status = "idle";
session.threadId = resumeThreadId;
@@ -1241,7 +1241,7 @@ export function createCodexStdioSessionManager(options = {}) {
lastTraceId: optionalId(params.traceId),
currentTraceId: null,
turn: 0,
threadId: requestedThreadId,
threadId,
durable: true,
longLivedSession: true,
codexStdio: true,
@@ -1249,11 +1249,11 @@ export function createCodexStdioSessionManager(options = {}) {
secretMaterialStored: false
};
sessions.set(effectiveSessionId, session);
reused = Boolean(requestedThreadId);
reused = Boolean(threadId);
}
session.conversationIds.add(conversationId);
session.threadId = requestedThreadId ?? session.threadId;
session.threadId = threadId ?? session.threadId;
session.workspace = params.workspace ?? session.workspace;
session.sandbox = params.sandbox ?? session.sandbox;
session.status = "busy";
+239 -20
View File
@@ -159,7 +159,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
leaseExpiresAt: "2999-01-01T00:00:00.000Z",
backendProfile: "deepseek",
sessionRef: {
sessionId: "ses_server-test-agentrun",
sessionId: "ses_agentrun_deepseek_server_test_agentrun",
conversationId: "cnv_server-test-agentrun",
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
metadata: {}
@@ -171,21 +171,35 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
assert.equal(body.backendProfile, "deepseek");
assert.equal(body.resourceBundleRef.repoUrl, "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git");
assert.equal(body.resourceBundleRef.commitId, "0123456789abcdef0123456789abcdef01234567");
assert.equal(Object.hasOwn(body.sessionRef, "threadId"), false);
assert.equal(body.sessionRef.sessionId, "ses_server-test-agentrun");
assert.deepEqual(body.resourceBundleRef.toolAliases, [
{ name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" }
]);
assert.equal(body.sessionRef.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(body.sessionRef.sessionId, "ses_agentrun_deepseek_server_test_agentrun");
assert.equal(body.sessionRef.metadata.hwlabSessionId, "ses_server-test-agentrun");
assert.equal(body.sessionRef.metadata.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(body.sessionRef.metadata.agentRunSessionProfile, "deepseek");
assert.equal(body.sessionRef.metadata.agentRunSessionPolicy, "backend-profile-scoped");
assert.equal(body.sessionRef.metadata.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread");
assert.equal(body.sessionRef.metadata.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse");
return send({ id: "run_hwlab_adapter", status: "pending", backendProfile: "deepseek", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef });
}
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands") {
if (body.type === "steer") {
assert.equal(body.payload.targetTraceId, "trc_server-test-agentrun-adapter");
assert.equal(body.payload.targetCommandId, "cmd_hwlab_adapter");
assert.equal(body.payload.traceId, "trc_steer_server_test");
assert.match(body.payload.prompt, /STEER_MARK/u);
assert.equal(body.payload.sessionId, "ses_agentrun_deepseek_server_test_agentrun");
assert.equal(body.payload.hwlabSessionId, "ses_server-test-agentrun");
assert.equal(body.payload.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(body.idempotencyKey, "trc_steer_server_test");
return send({ id: "cmd_hwlab_adapter_steer", runId: "run_hwlab_adapter", state: "pending", type: "steer", seq: 7 });
}
assert.equal(body.type, "turn");
assert.match(body.payload.prompt, /AgentRun adapter/u);
assert.equal(body.payload.sessionId, "ses_server-test-agentrun");
assert.equal(body.payload.sessionId, "ses_agentrun_deepseek_server_test_agentrun");
assert.equal(body.payload.hwlabSessionId, "ses_server-test-agentrun");
assert.equal(body.payload.threadId, null);
assert.equal(body.payload.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(body.payload.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(body.payload.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread");
assert.equal(body.payload.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse");
const secondTurn = //u.test(body.payload.prompt);
@@ -211,6 +225,8 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
assert.equal(transientEnv.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME, "1");
assert.equal(transientEnv.HWLAB_DEVICE_POD_SESSION_TOKEN, "test-device-pod-session-token");
assert.ok(body.transientEnv.length >= 7);
assert.equal(Object.hasOwn(transientEnv, "HWLAB_DEVICE_POD_API_URL"), false);
assert.equal(Object.hasOwn(transientEnv, "HWLAB_CODE_AGENT_DEVICE_POD_API_URL"), false);
return send({
action: "create-kubernetes-job",
runId: "run_hwlab_adapter",
@@ -224,15 +240,20 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
});
}
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/events") {
const second = url.searchParams.get("afterSeq") === "3" || calls.some((call) => call.path === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter_second/result");
const afterSeq = Number.parseInt(url.searchParams.get("afterSeq") ?? "0", 10);
const second = afterSeq >= 5 || url.searchParams.get("afterSeq") === "3" || calls.some((call) => call.path === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter_second/result");
if (second) return send({ items: [
{ id: "evt_old_tail", runId: "run_hwlab_adapter", seq: 6, type: "assistant_message", payload: { commandId: "cmd_hwlab_adapter", text: "旧 command 尾部不应进入第二轮。" }, createdAt: "2026-06-01T00:00:02.500Z" },
{ id: "evt_7", runId: "run_hwlab_adapter", seq: 7, type: "backend_status", payload: { phase: "turn-started", commandId: "cmd_hwlab_adapter_second", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:03.000Z" },
{ id: "evt_8", runId: "run_hwlab_adapter", seq: 8, type: "assistant_message", payload: { commandId: "cmd_hwlab_adapter_second", text: "AgentRun adapter 复用已有 runner 完成第二轮。" }, createdAt: "2026-06-01T00:00:04.000Z" },
{ id: "evt_9", runId: "run_hwlab_adapter", seq: 9, type: "terminal_status", payload: { commandId: "cmd_hwlab_adapter_second", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:05.000Z" }
] });
return send({ items: [
{ id: second ? "evt_4" : "evt_1", runId: "run_hwlab_adapter", seq: second ? 4 : 1, type: "backend_status", payload: { phase: second ? "turn-started" : "runner-job-created", commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:00.000Z" },
...(second ? [] : [
{ id: "evt_1", runId: "run_hwlab_adapter", seq: 1, type: "backend_status", payload: { phase: "runner-job-created", commandId: "cmd_hwlab_adapter", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:00.000Z" },
{ id: "evt_tool", runId: "run_hwlab_adapter", seq: 2, type: "tool_call", payload: { method: "item/completed", item: { type: "commandExecution", id: "call_agentrun_tool", command: "/bin/sh -lc 'hwpod profile list'", status: "completed", exitCode: 0, durationMs: 708, aggregatedOutput: '{"ok":true,"action":"profile.list"}' }, summary: { outputBytes: 42, outputTruncated: false }, commandId: "cmd_hwlab_adapter", runnerId: "runner_hwlab_adapter", attemptId: "attempt_hwlab_adapter" }, createdAt: "2026-06-01T00:00:00.500Z" },
{ id: "evt_noise", runId: "run_hwlab_adapter", seq: 3, type: "backend_status", payload: { phase: "thread/status/changed", commandId: "cmd_hwlab_adapter" }, createdAt: "2026-06-01T00:00:00.750Z" }
]),
{ id: second ? "evt_5" : "evt_2", runId: "run_hwlab_adapter", seq: second ? 5 : 4, type: "assistant_message", payload: { commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", text: second ? "AgentRun adapter 复用已有 runner 完成第二轮。" : "AgentRun adapter 已接管 HWLAB Code Agent。" }, createdAt: "2026-06-01T00:00:01.000Z" },
{ id: second ? "evt_6" : "evt_3", runId: "run_hwlab_adapter", seq: second ? 6 : 5, type: "terminal_status", payload: { commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:02.000Z" }
{ id: "evt_noise", runId: "run_hwlab_adapter", seq: 3, type: "backend_status", payload: { phase: "thread/status/changed", commandId: "cmd_hwlab_adapter" }, createdAt: "2026-06-01T00:00:00.750Z" },
{ id: "evt_2", runId: "run_hwlab_adapter", seq: 4, type: "assistant_message", payload: { commandId: "cmd_hwlab_adapter", text: "AgentRun adapter 已接管 HWLAB Code Agent。" }, createdAt: "2026-06-01T00:00:01.000Z" },
{ id: "evt_3", runId: "run_hwlab_adapter", seq: 5, type: "terminal_status", payload: { commandId: "cmd_hwlab_adapter", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:02.000Z" }
] });
}
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter/result") {
@@ -251,7 +272,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
reply: "AgentRun adapter 已接管 HWLAB Code Agent。",
lastSeq: 5,
eventCount: 5,
sessionRef: { sessionId: "ses_server-test-agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" }
sessionRef: { sessionId: "ses_agentrun_deepseek_server_test_agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" }
});
}
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter_second/result") {
@@ -268,9 +289,9 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
terminalStatus: "completed",
completed: true,
reply: "AgentRun adapter 复用已有 runner 完成第二轮。",
lastSeq: 6,
eventCount: 6,
sessionRef: { sessionId: "ses_server-test-agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" }
lastSeq: 9,
eventCount: 9,
sessionRef: { sessionId: "ses_agentrun_deepseek_server_test_agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" }
});
}
response.writeHead(404, { "content-type": "application/json" });
@@ -372,6 +393,29 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
assert.match(commandTraceEvent.command, /hwpod profile list/u);
assert.match(commandTraceEvent.stdoutSummary, /profile\.list/u);
const steer = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/steer`, {
method: "POST",
headers: { "content-type": "application/json", "x-trace-id": traceId },
body: JSON.stringify({
traceId,
steerTraceId: "trc_steer_server_test",
conversationId: "cnv_server-test-agentrun",
sessionId: "ses_server-test-agentrun",
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
message: "请按 STEER_MARK 调整最终回复"
})
});
assert.equal(steer.status, 202);
const steerBody = await steer.json();
assert.equal(steerBody.accepted, true);
assert.equal(steerBody.route, "/v1/agent/chat/steer");
assert.equal(steerBody.traceId, traceId);
assert.equal(steerBody.steerTraceId, "trc_steer_server_test");
assert.equal(steerBody.agentRun.runId, "run_hwlab_adapter");
assert.equal(steerBody.agentRun.targetCommandId, "cmd_hwlab_adapter");
assert.equal(steerBody.agentRun.steerCommandId, "cmd_hwlab_adapter_steer");
assert.ok(steerBody.runnerTrace.events.some((event) => event.label === "agentrun:steer:command-created"));
const secondTraceId = "trc_server-test-agentrun-adapter-second";
const second = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
method: "POST",
@@ -401,9 +445,10 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
assert.match(secondPayload.reply.content, / runner/u);
assert.ok(secondPayload.runnerTrace.events.some((event) => event.label === "agentrun:run:reused"));
assert.ok(secondPayload.runnerTrace.events.some((event) => event.label === "agentrun:runner-job:reused"));
assert.equal(secondPayload.runnerTrace.events.some((event) => String(event.text ?? event.message ?? "").includes("旧 command 尾部")), false);
assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs").length, 1);
assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs/run_hwlab_adapter/runner-jobs").length, 1);
assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs/run_hwlab_adapter/commands").length, 2);
assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs/run_hwlab_adapter/commands").length, 3);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve())));
@@ -424,6 +469,10 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba
};
if (request.method === "POST" && url.pathname === "/api/v1/runs") {
assert.equal(body.backendProfile, "minimax-m3");
assert.equal(body.sessionRef.sessionId, "ses_agentrun_minimax_m3_server_test_minimax_m3");
assert.equal(body.sessionRef.metadata.hwlabSessionId, "ses_server-test-minimax-m3");
assert.equal(body.sessionRef.metadata.agentRunSessionProfile, "minimax-m3");
assert.equal(body.sessionRef.metadata.agentRunSessionPolicy, "backend-profile-scoped");
assert.equal(body.executionPolicy.secretScope.providerCredentials[0].profile, "minimax-m3");
assert.equal(body.executionPolicy.secretScope.providerCredentials[0].secretRef.name, "agentrun-v01-provider-minimax-m3");
assert.equal(body.executionPolicy.secretScope.providerCredentials[0].secretRef.namespace, "agentrun-v01");
@@ -431,6 +480,8 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba
}
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_minimax_m3/commands") {
assert.equal(body.payload.providerProfile, "minimax-m3");
assert.equal(body.payload.sessionId, "ses_agentrun_minimax_m3_server_test_minimax_m3");
assert.equal(body.payload.hwlabSessionId, "ses_server-test-minimax-m3");
assert.match(body.payload.prompt, /MiniMax-M3/u);
return send({ id: "cmd_hwlab_minimax_m3", runId: "run_hwlab_minimax_m3", state: "pending", type: "turn", seq: 1 });
}
@@ -471,7 +522,7 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba
reply: "AGENTRUN_MINIMAX_M3_OK",
lastSeq: 3,
eventCount: 3,
sessionRef: { sessionId: "ses_server-test-minimax-m3", conversationId: "cnv_server-test-minimax-m3", threadId: null }
sessionRef: { sessionId: "ses_agentrun_minimax_m3_server_test_minimax_m3", conversationId: "cnv_server-test-minimax-m3", threadId: null }
});
}
response.writeHead(404, { "content-type": "application/json" });
@@ -521,6 +572,8 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba
assert.equal(payload.status, "completed");
assert.equal(payload.infrastructureBackend, "agentrun-v01/minimax-m3");
assert.equal(payload.agentRun.backendProfile, "minimax-m3");
assert.equal(payload.sessionId, "ses_server-test-minimax-m3");
assert.equal(payload.agentRun.sessionId, "ses_agentrun_minimax_m3_server_test_minimax_m3");
assert.equal(payload.providerTrace.backendProfile, "minimax-m3");
assert.equal(payload.agentRun.jobName, "agentrun-v01-runner-hwlab-minimax-m3");
assert.equal(payload.reply.content, "AGENTRUN_MINIMAX_M3_OK");
@@ -531,6 +584,172 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba
}
});
test("cloud api AgentRun adapter scopes AgentRun sessions by backend profile", async () => {
const calls = [];
const hwlabSessionId = "ses_server-test-profile-switch";
const minimaxSessionId = "ses_agentrun_minimax_m3_server_test_profile_switch";
const ownerSessions = new Map([[hwlabSessionId, {
ownerUserId: "usr_agent_owner",
ownerRole: "user",
sessionId: hwlabSessionId,
conversationId: "cnv_server-test-profile-switch",
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
traceId: "trc_existing_deepseek",
status: "active",
session: {
agentRun: {
adapter: "agentrun-v01",
backendProfile: "deepseek",
runId: "run_existing_deepseek",
commandId: "cmd_existing_deepseek",
jobName: "agentrun-v01-runner-existing-deepseek",
namespace: "agentrun-v01",
sessionId: "ses_agentrun_deepseek_server_test_profile_switch",
reuseEligible: true,
managerUrl: "http://127.0.0.1:1",
status: "runner-job-created"
}
}
}]]);
const agentRunServer = createHttpServer(async (request, response) => {
const url = new URL(request.url || "/", "http://127.0.0.1");
const chunks = [];
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null;
calls.push({ method: request.method, path: url.pathname, body });
const send = (data) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_profile_switch" })}\n`);
};
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_existing_deepseek") {
response.writeHead(500, { "content-type": "application/json" });
response.end(`${JSON.stringify({ ok: false, failureKind: "unexpected-reuse", message: "deepseek run must not be reused for minimax-m3" })}\n`);
return;
}
if (request.method === "POST" && url.pathname === "/api/v1/runs") {
assert.equal(body.backendProfile, "minimax-m3");
assert.equal(body.sessionRef.sessionId, minimaxSessionId);
assert.equal(body.sessionRef.metadata.hwlabSessionId, hwlabSessionId);
assert.equal(body.sessionRef.metadata.agentRunSessionProfile, "minimax-m3");
return send({ id: "run_hwlab_profile_switch_m3", status: "pending", backendProfile: "minimax-m3", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef });
}
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_profile_switch_m3/commands") {
assert.equal(body.payload.providerProfile, "minimax-m3");
assert.equal(body.payload.sessionId, minimaxSessionId);
assert.equal(body.payload.hwlabSessionId, hwlabSessionId);
return send({ id: "cmd_hwlab_profile_switch_m3", runId: "run_hwlab_profile_switch_m3", state: "pending", type: "turn", seq: 1 });
}
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_profile_switch_m3/runner-jobs") {
assert.equal(body.commandId, "cmd_hwlab_profile_switch_m3");
return send({
action: "create-kubernetes-job",
runId: "run_hwlab_profile_switch_m3",
commandId: "cmd_hwlab_profile_switch_m3",
attemptId: "attempt_hwlab_profile_switch_m3",
runnerId: "runner_hwlab_profile_switch_m3",
namespace: "agentrun-v01",
jobName: "agentrun-v01-runner-profile-switch-m3",
jobIdentity: { namespace: "agentrun-v01", name: "agentrun-v01-runner-profile-switch-m3" },
runner: { attemptId: "attempt_hwlab_profile_switch_m3", runnerId: "runner_hwlab_profile_switch_m3" }
});
}
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_profile_switch_m3/events") {
return send({ items: [
{ id: "evt_profile_switch_1", runId: "run_hwlab_profile_switch_m3", seq: 1, type: "backend_status", payload: { phase: "runner-job-created", commandId: "cmd_hwlab_profile_switch_m3", attemptId: "attempt_hwlab_profile_switch_m3", jobName: "agentrun-v01-runner-profile-switch-m3", namespace: "agentrun-v01" }, createdAt: "2026-06-02T00:00:00.000Z" },
{ id: "evt_profile_switch_2", runId: "run_hwlab_profile_switch_m3", seq: 2, type: "assistant_message", payload: { commandId: "cmd_hwlab_profile_switch_m3", text: "AGENTRUN_HWLAB_MINIMAX_M3_OK" }, createdAt: "2026-06-02T00:00:01.000Z" },
{ id: "evt_profile_switch_3", runId: "run_hwlab_profile_switch_m3", seq: 3, type: "terminal_status", payload: { commandId: "cmd_hwlab_profile_switch_m3", terminalStatus: "completed" }, createdAt: "2026-06-02T00:00:02.000Z" }
] });
}
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_profile_switch_m3/commands/cmd_hwlab_profile_switch_m3/result") {
return send({
runId: "run_hwlab_profile_switch_m3",
commandId: "cmd_hwlab_profile_switch_m3",
attemptId: "attempt_hwlab_profile_switch_m3",
runnerId: "runner_hwlab_profile_switch_m3",
jobName: "agentrun-v01-runner-profile-switch-m3",
namespace: "agentrun-v01",
status: "completed",
runStatus: "completed",
commandState: "completed",
terminalStatus: "completed",
completed: true,
reply: "AGENTRUN_HWLAB_MINIMAX_M3_OK",
lastSeq: 3,
eventCount: 3,
sessionRef: { sessionId: minimaxSessionId, conversationId: "cnv_server-test-profile-switch", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" }
});
}
response.writeHead(404, { "content-type": "application/json" });
response.end(`${JSON.stringify({ ok: false, failureKind: "schema-invalid", message: `unexpected ${request.method} ${url.pathname}`, traceId: "trc_fake_profile_switch" })}\n`);
});
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
const agentRunPort = agentRunServer.address().port;
const server = createCloudApiServer({
env: {
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`,
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1",
HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567",
HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek",
HWLAB_ENVIRONMENT: "v02",
HWLAB_GITOPS_PROFILE: "v02"
},
accessController: {
required: false,
async authenticate() {
return { ok: true, actor: null, session: null };
},
async recordAgentSessionOwner(input) {
ownerSessions.set(input.sessionId, input);
return input;
},
async getAgentSession(sessionId) {
return ownerSessions.get(sessionId) ?? null;
}
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const traceId = "trc_server-test-agentrun-profile-switch";
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
method: "POST",
headers: { "content-type": "application/json", "x-trace-id": traceId },
body: JSON.stringify({
conversationId: "cnv_server-test-profile-switch",
sessionId: hwlabSessionId,
ownerUserId: "usr_agent_owner",
ownerRole: "user",
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
providerProfile: "minimax-m3",
message: "Switch this HWLAB session to MiniMax-M3 through AgentRun"
})
});
assert.equal(submit.status, 202);
const payload = await pollAgentResult(port, traceId);
validateCodeAgentChatSchema(payload);
assert.equal(payload.status, "completed");
assert.equal(payload.sessionId, hwlabSessionId);
assert.equal(payload.infrastructureBackend, "agentrun-v01/minimax-m3");
assert.equal(payload.agentRun.backendProfile, "minimax-m3");
assert.equal(payload.agentRun.runId, "run_hwlab_profile_switch_m3");
assert.equal(payload.agentRun.sessionId, minimaxSessionId);
assert.equal(payload.reply.content, "AGENTRUN_HWLAB_MINIMAX_M3_OK");
assert.equal(calls.some((call) => call.method === "GET" && call.path === "/api/v1/runs/run_existing_deepseek"), false);
assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs").length, 1);
const stored = ownerSessions.get(hwlabSessionId);
assert.equal(stored.session.agentRun.backendProfile, "minimax-m3");
assert.equal(stored.session.agentRun.sessionId, minimaxSessionId);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve())));
}
});
test("cloud api AgentRun adapter rejects non-internal manager URLs by default", async () => {
const server = createCloudApiServer({
env: {
+166 -1
View File
@@ -9,6 +9,7 @@ import {
initialAgentRunChatResult,
loadPersistedAgentRunResult,
refreshAgentRunTrace,
steerAgentRunChatTurn,
submitAgentRunChatTurn,
syncAgentRunChatResult
} from "./code-agent-agentrun-adapter.ts";
@@ -344,6 +345,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) {
runnerTrace: traceStore.snapshot(traceId),
updatedAt: new Date().toISOString()
}, params);
recordCodeAgentConversationFact(payload, executionOptions);
results.set(traceId, payload);
traceStore.append(traceId, {
type: "result",
@@ -478,6 +480,7 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti
return;
}
if (synced.result && synced.result.status !== "running") {
recordCodeAgentConversationFact(synced.result, options);
await recordCodeAgentSessionOwner({ payload: synced.result, params: synced.result, options, status: synced.result.status === "completed" ? "active" : synced.result.status });
sendJson(response, 200, compactCodeAgentChatResultPayload(synced.result, options));
return;
@@ -837,10 +840,165 @@ export async function handleCodeAgentCancelHttp(request, response, options) {
updatedAt: new Date().toISOString()
};
await recordCodeAgentSessionOwner({ payload, params: { ...params, traceId, ownerUserId: options.actor?.id, ownerRole: options.actor?.role }, options, status: "canceled" });
recordCodeAgentConversationFact(payload, options);
options.codeAgentChatResults?.set(traceId, annotateOwner(payload, { ownerUserId: options.actor?.id, ownerRole: options.actor?.role }));
sendJson(response, 200, payload);
}
function recordCodeAgentConversationFact(payload = {}, options = {}) {
const registry = options.sessionRegistry;
const conversationId = safeConversationId(payload.conversationId);
if (!conversationId || typeof registry?.recordFact !== "function") return null;
try {
return registry.recordFact(conversationId, {
kind: payload.status === "completed" ? "runner_turn" : "runner_turn_terminal",
conversationId,
sessionId: payload.sessionId,
traceId: payload.traceId,
provider: payload.provider,
backend: payload.backend,
workspace: payload.workspace,
sandbox: payload.sandbox,
session: payload.session,
sessionMode: payload.sessionMode,
sessionReuse: payload.sessionReuse,
implementationType: payload.implementationType,
runner: payload.runner,
runnerTrace: payload.runnerTrace,
partialContext: payload.partialContext ?? payload.runnerTrace?.partialContext,
capabilityLevel: payload.capabilityLevel,
toolCalls: payload.toolCalls,
skills: payload.skills
}, { now: options.now });
} catch {
return null;
}
}
export async function handleCodeAgentSteerHttp(request, response, options) {
const body = await readBody(request, options.bodyLimitBytes);
let params = {};
try {
params = body ? JSON.parse(body) : {};
} catch (error) {
sendJson(response, 400, {
...createCodeAgentErrorPayload({
code: "parse_error",
message: "Invalid JSON body",
reason: error.message,
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
layer: "api",
retryable: true,
route: "/v1/agent/chat/steer"
})
});
return;
}
if (!params || typeof params !== "object" || Array.isArray(params)) {
sendJson(response, 400, {
...createCodeAgentErrorPayload({
code: "invalid_params",
message: "Code Agent steer body must be a JSON object",
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
layer: "api",
retryable: true,
route: "/v1/agent/chat/steer"
})
});
return;
}
const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId || params.targetTraceId);
const message = firstNonEmptyValue(params.message, params.prompt, params.text);
if (!traceId || !message) {
sendJson(response, 400, {
...createCodeAgentErrorPayload({
code: !traceId ? "steer_trace_missing" : "steer_message_missing",
message: !traceId ? "traceId is required to steer the current Code Agent turn." : "message is required to steer the current Code Agent turn.",
traceId: traceId || "trc_unassigned",
layer: "api",
retryable: true,
route: "/v1/agent/chat/steer"
})
});
return;
}
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
const currentResult = options.codeAgentChatResults?.get?.(traceId) ?? await loadPersistedAgentRunResult(traceId, options);
if (!canAccessOwnedResult(currentResult, options.actor)) {
sendJson(response, 403, {
ok: false,
status: "forbidden",
traceId,
error: { code: "code_agent_trace_forbidden", message: "Code Agent trace is not visible to the current actor" }
});
return;
}
if (!currentResult?.agentRun?.runId || !currentResult?.agentRun?.commandId) {
sendJson(response, 404, {
ok: false,
status: "not_found",
traceId,
error: { code: "steer_trace_not_found", message: "No AgentRun-backed Code Agent turn was found for the requested traceId." },
route: "/v1/agent/chat/steer",
runnerTrace: traceStore.snapshot(traceId)
});
return;
}
try {
const payload = await steerAgentRunChatTurn({
traceId,
currentResult,
params: {
...params,
message,
ownerUserId: options.actor?.id ?? params.ownerUserId,
ownerRole: options.actor?.role ?? params.ownerRole
},
options,
traceStore
});
await recordCodeAgentSessionOwner({ payload: currentResult, params: { ...params, traceId, ownerUserId: options.actor?.id, ownerRole: options.actor?.role }, options, status: "running" });
sendJson(response, 202, payload);
} catch (error) {
traceStore.append(traceId, {
type: "backend",
status: "failed",
label: "agentrun:steer:failed",
errorCode: error?.code ?? "agentrun_steer_failed",
message: error?.message ?? "AgentRun steer command failed.",
terminal: false,
valuesPrinted: false
});
sendJson(response, error?.statusCode === 404 ? 404 : 409, {
ok: false,
accepted: false,
status: "failed",
traceId,
route: "/v1/agent/chat/steer",
conversationId: safeConversationId(currentResult.conversationId ?? params.conversationId) || null,
sessionId: safeSessionId(currentResult.sessionId ?? params.sessionId) || null,
threadId: safeOpaqueId(currentResult.threadId ?? params.threadId) || null,
agentRun: currentResult.agentRun,
runnerTrace: traceStore.snapshot(traceId),
error: {
code: error?.code ?? "agentrun_steer_failed",
layer: "agentrun",
category: "steer_failed",
retryable: true,
message: error?.message ?? "AgentRun steer command failed.",
traceId,
route: "/v1/agent/chat/steer",
toolName: "agentrun.command.steer"
}
});
}
}
async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options = {}, status = "active" } = {}) {
const ownerUserId = options.actor?.id ?? params.ownerUserId;
if (!ownerUserId || !options.accessController?.recordAgentSessionOwner) return null;
@@ -945,6 +1103,9 @@ async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId,
});
}
if (activeTraceBlocksTurn) {
const conversationId = safeConversationId(params.conversationId) || selectedConversationId || activeResult?.conversationId || null;
const sessionId = safeSessionId(params.sessionId) || safeSessionId(workspace.selectedAgentSessionId) || activeResult?.sessionId || null;
const threadId = safeOpaqueId(params.threadId) || activeResult?.threadId || null;
sendJson(response, 409, {
ok: false,
status: "workspace_agent_busy",
@@ -952,6 +1113,10 @@ async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId,
workspaceId,
workspaceRevision: workspace.revision,
activeTraceId,
conversationId,
sessionId,
threadId,
providerTrace: activeResult?.providerTrace ?? activeResult?.agentRun?.providerTrace ?? null,
resultUrl: `/v1/agent/chat/result/${encodeURIComponent(activeTraceId)}`,
traceUrl: `/v1/agent/chat/trace/${encodeURIComponent(activeTraceId)}`
});
@@ -989,7 +1154,7 @@ function isActiveWorkbenchConversation(conversation = null) {
for (const message of Array.isArray(conversation.messages) ? conversation.messages.slice(-5) : []) {
values.push(message?.status, message?.runnerTrace?.sessionStatus, message?.sessionStatus);
}
return values.some((value) => ["running", "busy", "pending", "active", "queued"].includes(textValue(value).toLowerCase()));
return values.some((value) => ["running", "busy", "pending", "queued"].includes(textValue(value).toLowerCase()));
}
function isWorkbenchOrphanActiveTrace(snapshot) {
+8
View File
@@ -51,6 +51,7 @@ import {
handleCodeAgentChatHttp,
handleCodeAgentChatResultHttp,
handleCodeAgentInspectHttp,
handleCodeAgentSteerHttp,
handleCodeAgentTraceHttp
} from "./server-code-agent-http.ts";
import { handleM3IoControlHttp } from "./server-m3-http.ts";
@@ -452,6 +453,13 @@ async function handleRestAdapter(request, response, url, options) {
return;
}
if (request.method === "POST" && url.pathname === "/v1/agent/chat/steer") {
const nextOptions = await codeAgentOptions(request, response, options);
if (!nextOptions) return;
await handleCodeAgentSteerHttp(request, response, nextOptions);
return;
}
if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/trace/")) {
const nextOptions = await codeAgentOptions(request, response, options);
if (!nextOptions) return;
+3 -1
View File
@@ -6,6 +6,7 @@ const POST_PROXY_ROUTES = new Set([
"/json-rpc",
"/v1/agent/chat",
"/v1/agent/chat/cancel",
"/v1/agent/chat/steer",
"/v1/m3/io",
"/v1/skills/uploads"
]);
@@ -14,7 +15,8 @@ const PUBLIC_PROXY_ROUTES = new Set([
"POST /auth/login",
"POST /auth/logout",
"POST /v1/agent/chat",
"POST /v1/agent/chat/cancel"
"POST /v1/agent/chat/cancel",
"POST /v1/agent/chat/steer"
]);
export function cloudWebProxyRoutePolicy(method, pathname) {
@@ -89,3 +89,12 @@ test("cloud web proxies authenticated account workbench write routes", () => {
routeKey: "POST /v1/workbench/workspace/ws_default/events"
});
});
test("cloud web proxies Code Agent steer through the Web-equivalent public API path", () => {
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/agent/chat/steer"), {
proxy: true,
authRequired: false,
publicRoute: true,
routeKey: "POST /v1/agent/chat/steer"
});
});
+40 -25
View File
@@ -109,31 +109,16 @@ export function proxyHttpRequest({ request, response, upstream, timeoutMs = DEFA
const target = new URL(request.url || "/", upstream);
const traceId = request.headers["x-trace-id"] ?? null;
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
proxy.destroy(new Error(`upstream timed out after ${timeoutMs}ms`));
}, timeoutMs);
const proxy = httpRequest(
target,
{
method: request.method,
headers: {
...request.headers,
host: target.host
}
},
(upstreamResponse) => {
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
upstreamResponse.on("end", () => clearTimeout(timeout));
upstreamResponse.on("close", () => clearTimeout(timeout));
upstreamResponse.pipe(response);
}
);
let settled = false;
proxy.on("error", (error) => {
clearTimeout(timeout);
if (settled || response.headersSent) {
let timeout = null;
const clearProxyTimeout = () => {
if (timeout) clearTimeout(timeout);
timeout = null;
};
const sendProxyFailure = (error) => {
clearProxyTimeout();
if (settled) return;
if (response.headersSent) {
response.destroy(error);
return;
}
@@ -170,7 +155,37 @@ export function proxyHttpRequest({ request, response, upstream, timeoutMs = DEFA
traceId,
message: userMessage
});
});
};
const proxy = httpRequest(
target,
{
method: request.method,
headers: {
...request.headers,
host: target.host
}
},
(upstreamResponse) => {
if (settled) {
upstreamResponse.resume();
return;
}
settled = true;
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
upstreamResponse.on("end", clearProxyTimeout);
upstreamResponse.on("close", clearProxyTimeout);
upstreamResponse.pipe(response);
}
);
timeout = setTimeout(() => {
timedOut = true;
const error = new Error(`upstream timed out after ${timeoutMs}ms`);
sendProxyFailure(error);
proxy.destroy(error);
}, timeoutMs);
proxy.on("error", sendProxyFailure);
request.pipe(proxy);
}
+6
View File
@@ -19,6 +19,12 @@ test("cloud web route policy proxies public Code Agent chat without gating other
publicRoute: true,
routeKey: "POST /v1/agent/chat/cancel"
});
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/agent/chat/steer"), {
proxy: true,
authRequired: false,
publicRoute: true,
routeKey: "POST /v1/agent/chat/steer"
});
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/m3/io"), {
proxy: true,
authRequired: true,
+60
View File
@@ -499,6 +499,66 @@ test("hwlab-cli client agent send can replay continuation from an existing trace
assert.equal(JSON.stringify(result.payload).includes("hwlab_session=session-a"), false);
});
test("hwlab-cli client agent steer posts to Web-equivalent steer route", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"agent",
"steer",
"trc_target_steer",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--message",
"please include STEER_MARK",
"--steer-trace-id",
"trc_steer_cli",
"--conversation-id",
"cnv_steer",
"--session-id",
"ses_steer",
"--thread-id",
"thread-steer"
], {
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
return new Response(JSON.stringify({
ok: true,
accepted: true,
status: "running",
route: "/v1/agent/chat/steer",
traceId: "trc_target_steer",
steerTraceId: "trc_steer_cli",
conversationId: "cnv_steer",
sessionId: "ses_steer",
agentRun: { runId: "run_steer", targetCommandId: "cmd_turn", steerCommandId: "cmd_steer" },
traceUrl: "/v1/agent/chat/trace/trc_target_steer",
resultUrl: "/v1/agent/chat/result/trc_target_steer"
}), { status: 202 });
},
sleep: async () => {}
});
assert.equal(result.exitCode, 0);
assert.equal(calls[0].url, "http://web.test/v1/agent/chat/steer");
assert.equal(calls[0].init.headers["x-trace-id"], "trc_target_steer");
assert.equal(calls[0].init.headers.prefer, "respond-async");
assert.equal(calls[0].body.traceId, "trc_target_steer");
assert.equal(calls[0].body.targetTraceId, "trc_target_steer");
assert.equal(calls[0].body.steerTraceId, "trc_steer_cli");
assert.equal(calls[0].body.message, "please include STEER_MARK");
assert.equal(calls[0].body.conversationId, "cnv_steer");
assert.equal(calls[0].body.sessionId, "ses_steer");
assert.equal(calls[0].body.threadId, "thread-steer");
assert.equal(result.payload.action, "client.agent.steer");
assert.equal(result.payload.route.path, "/v1/agent/chat/steer");
assert.equal(result.payload.traceId, "trc_target_steer");
assert.equal(result.payload.steerTraceId, "trc_steer_cli");
assert.equal(result.payload.body.agentRun.steerCommandId, "cmd_steer");
assert.equal(result.payload.waitPolicy.webEquivalent, true);
});
test("hwlab-cli client agent trace shows assistant stream text in compact output", async () => {
const result = await runHwlabCli(["client", "agent", "trace", "trc_test", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], {
fetchImpl: async () => new Response(JSON.stringify({
+71 -1
View File
@@ -129,6 +129,7 @@ function help() {
"hwlab-cli client agent trace TRACE_ID [--render web]",
"hwlab-cli client agent inspect --trace-id TRACE_ID",
"hwlab-cli client agent cancel TRACE_ID",
"hwlab-cli client agent steer TRACE_ID --message TEXT|--message-file PATH",
"hwlab-cli client harness submit --message TEXT --provider-profile deepseek|codex-api|minimax-m3",
"hwlab-cli client harness wait TRACE_ID --timeout-ms 50000",
"hwlab-cli client harness audit TRACE_ID --require-bootsharp"
@@ -603,6 +604,7 @@ async function agentCommand(context: any) {
const response = await requestJson({ ...context, method: "GET", path: pathName });
return responsePayload("client.agent.inspect", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) });
}
if (subcommand === "steer") return agentSteer(context);
if (subcommand === "cancel") {
const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId");
const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/chat/cancel", body: clean({ traceId, conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId), threadId: text(context.parsed.threadId) }), extraHeaders: { "x-trace-id": traceId } });
@@ -621,11 +623,55 @@ function agentHelp() {
"result TRACE_ID [--full]",
"trace TRACE_ID [--render web] [--limit N] [--full]",
"inspect --trace-id TRACE_ID|--conversation-id ID|--session-id ID|--thread-id ID",
"steer TRACE_ID --message TEXT|--message-file PATH [--conversation-id ID] [--session-id ID] [--thread-id ID]",
"cancel TRACE_ID"
]
});
}
async function agentSteer(context: any) {
const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId ?? context.parsed.targetTraceId);
const message = await messageValue(context);
if (!message) throw cliError("message_required", "client agent steer requires --message or stdin text");
const steerTraceId = text(context.parsed.steerTraceId) || makeId("trc_steer");
const body = clean({
traceId,
targetTraceId: traceId,
steerTraceId,
message,
conversationId: text(context.parsed.conversationId),
sessionId: text(context.parsed.sessionId),
threadId: text(context.parsed.threadId)
});
const response = await requestJson({
...context,
method: "POST",
path: "/v1/agent/chat/steer",
body,
timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS,
extraHeaders: {
"x-trace-id": traceId,
"prefer": "respond-async",
"x-hwlab-short-connection": "1"
}
});
return responsePayload("client.agent.steer", response, context, {
route: route("POST", "/v1/agent/chat/steer"),
traceId,
steerTraceId,
body: responseBodyForCli(response.body, context.parsed),
waitPolicy: {
defaultWait: false,
webEquivalent: true,
reason: "Steer is applied to the active Code Agent turn; observe the original target trace/result to confirm whether the runner accepted and applied it.",
nextCommands: [
`hwlab-cli client agent result ${traceId}`,
`hwlab-cli client agent trace ${traceId} --render web`
]
}
});
}
function agentInspectPath(context: any) {
const params = new URLSearchParams();
const traceId = text(context.rest[1] ?? context.parsed.traceId);
@@ -1786,6 +1832,7 @@ function compactApiBody(body: any): any {
adapter: body.adapter,
sourceKind: body.sourceKind,
liveBackend: body.liveBackend,
agentRun: compactAgentRun(body.agentRun),
rowCount: body.rowCount,
columns: Array.isArray(body.columns) ? body.columns : undefined,
roles: Array.isArray(body.roles) ? body.roles : undefined,
@@ -1809,6 +1856,29 @@ function compactApiBody(body: any): any {
});
}
function compactAgentRun(value: any) {
if (!value || typeof value !== "object") return undefined;
return pruneUndefined({
adapter: value.adapter,
runId: value.runId,
commandId: value.commandId,
targetCommandId: value.targetCommandId,
steerCommandId: value.steerCommandId,
lastSteerCommandId: value.lastSteerCommandId,
attemptId: value.attemptId,
runnerId: value.runnerId,
jobName: value.jobName,
namespace: value.namespace,
backendProfile: value.backendProfile,
status: value.status,
runStatus: value.runStatus,
commandState: value.commandState,
terminalStatus: value.terminalStatus,
reuseEligible: value.reuseEligible,
valuesPrinted: false
});
}
function assistantText(body: any) {
const direct = text(body?.reply?.content ?? body?.message?.content ?? body?.assistantText);
if (direct) return direct;
@@ -1873,7 +1943,7 @@ function auditTrace(traceObject: any, { requireBootsharp = false } = {}) {
"long_device_pod_cli_path",
/node\s+\/app\/(?:tools\/device-pod-cli\.mjs|skills\/device-pod-cli\/scripts\/device-pod-cli\.mjs)/u,
textValue,
"Prefer hwpod in HWLAB code-agent runners; the long wrapper path is only a compatibility fallback when hwpod is absent."
"Use hwpod in HWLAB code-agent runners; if hwpod is absent, fix the runner image/package instead of invoking the long wrapper path."
),
signal(
"temporary_script_workaround",
+10 -11
View File
@@ -102,11 +102,11 @@ async function submitAgentMessage(value, options = {}) {
const traceId = options.traceId ?? nextProtocolId("trc");
const activeConversationId = options.conversationId ?? state.conversationId ?? nextProtocolId("cnv");
const requestedSessionId = options.sessionId === undefined ? sessionIdForNextRequest() : options.sessionId;
const requestedThreadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId;
const threadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId;
const pendingContinuity = pendingSessionContinuity({
conversationId: activeConversationId,
sessionId: requestedSessionId,
threadId: requestedThreadId,
threadId,
retryOf: options.retryOf
});
state.conversationId = activeConversationId;
@@ -119,7 +119,7 @@ async function submitAgentMessage(value, options = {}) {
traceId,
conversationId: activeConversationId,
sessionId: requestedSessionId,
threadId: requestedThreadId,
threadId,
retryOf: options.retryOf ?? null,
createdAt: new Date().toISOString()
};
@@ -132,7 +132,7 @@ async function submitAgentMessage(value, options = {}) {
traceId,
conversationId: activeConversationId,
sessionId: requestedSessionId,
threadId: requestedThreadId,
threadId,
sessionContinuity: pendingContinuity,
retryInput: value,
retryOf: options.retryOf ?? null,
@@ -145,7 +145,7 @@ async function submitAgentMessage(value, options = {}) {
traceId,
conversationId: activeConversationId,
sessionId: requestedSessionId,
threadId: requestedThreadId,
threadId,
messageId: pendingMessage.id,
input: value,
lastActivityAt: Date.now(),
@@ -165,14 +165,13 @@ async function submitAgentMessage(value, options = {}) {
const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id);
try {
const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, requestedThreadId);
const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, threadId);
stopTraceStream();
if (state.canceledTraces.has(traceId)) return;
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
const updatedMessage = applyCodeAgentResultToMessage(pendingMessage.id, result, {
baseMessage: pendingMessage,
pendingContinuity,
requestedThreadId,
retryInput: value,
retryOf: options.retryOf ?? null
});
@@ -200,7 +199,7 @@ async function submitAgentMessage(value, options = {}) {
text: presentation.text,
status: failedStatus,
traceId: error.traceId || traceId,
threadId: requestedThreadId,
threadId,
sessionContinuity: failedSessionContinuity(pendingContinuity, error),
updatedAt: new Date().toISOString(),
retryInput: value,
@@ -292,7 +291,7 @@ function applyCodeAgentResultToMessage(messageId, result, options = {}) {
traceId: result.traceId || current.traceId,
conversationId: result.conversationId || result.sessionId || state.conversationId,
sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId,
threadId: resultThreadId || options.requestedThreadId || current.threadId || state.threadId,
threadId: resultThreadId || current.threadId || state.threadId,
sessionContinuity,
messageId: result.messageId,
provider: result.provider,
@@ -782,9 +781,9 @@ function renderProbePending() {
renderCodeAgentSummary();
}
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest(), requestedThreadId = threadIdForNextRequest()) {
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest(), threadIdForRequest = threadIdForNextRequest()) {
const sessionId = requestedSessionId || undefined;
const threadId = requestedThreadId || undefined;
const threadId = threadIdForRequest || undefined;
const response = await fetchJson("/v1/agent/chat", {
method: "POST",
headers: {
+1 -1
View File
@@ -20,7 +20,7 @@ test("server auth is authoritative when restoring the workbench", async () => {
assert.equal(session.authenticated, true);
assert.equal(session.mode, "server");
assert.deepEqual(session.user, { username: "server-admin" });
assert.equal(session.user.username, "server-admin");
assert.deepEqual(harness.fetchCalls.map((call) => call.path), ["/auth/session"]);
assert.deepEqual(harness.fetchCalls.map((call) => call.method), ["GET"]);
assert.equal(harness.loginShell.hidden, true);