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:
@@ -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 被降级成
|
||||
|
||||
@@ -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 forwarder,forwarder 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 委托 profile,HWLAB 只负责把用户选择透传为 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 委托 profile,HWLAB 只负责把用户选择透传为 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 和最小闭环验证。
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 会被拒绝。
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
@@ -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/启动新 runner;result 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。
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user