diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index f9445f64..17a51739 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -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 ` 读取 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:` 是 route 语法,`/` 只用于 pod 内文件系统路径。 @@ -26,6 +26,7 @@ - `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 `,让 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 ` 是运行中引导入口,必须调用 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 --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。 @@ -58,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 秒。 | @@ -99,6 +101,10 @@ 阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:对一个来自 Cloud Web 的真实 trace 运行 `client agent trace --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 --message "请把最终回复包含 STEER_ACCEPTED 标记"`,最后用 `client agent result ` 和 `client agent trace --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 会被拒绝。 diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index b5783249..4ea6503c 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -40,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)。 @@ -52,7 +52,7 @@ ## 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 @@ -67,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。 | diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 25a4ff88..88591e9e 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -94,9 +94,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"); @@ -401,6 +401,86 @@ test("workbench workspace ignores stale active trace when selected conversation } }); +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(); diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index a69e72ef..8d3c824d 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -314,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); @@ -459,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 { diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 4af5fe71..99be8231 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -184,6 +184,17 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte 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_agentrun_deepseek_server_test_agentrun"); @@ -381,6 +392,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", @@ -413,7 +447,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte 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()))); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index f51c235a..9035ddf3 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -9,6 +9,7 @@ import { initialAgentRunChatResult, loadPersistedAgentRunResult, refreshAgentRunTrace, + steerAgentRunChatTurn, submitAgentRunChatTurn, syncAgentRunChatResult } from "./code-agent-agentrun-adapter.ts"; @@ -874,6 +875,130 @@ function recordCodeAgentConversationFact(payload = {}, options = {}) { } } +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; @@ -950,6 +1075,9 @@ async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId, activeTraceId && (activeResult?.status === "running" || (!activeResult && isActiveWorkbenchConversation(selectedConversation))) ); 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", @@ -957,6 +1085,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)}` }); @@ -994,7 +1126,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())); } async function resolveWorkbenchActiveTraceResult(traceId, options = {}) { diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index b3788ce5..ab282683 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -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; diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index fbd86a3f..944c65ab 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -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({ diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 00f37f8b..07e3b15d 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -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;