Merge pull request #653 from pikasTech/fix/v02-web-cli-agentrun-continuation

fix: align web cli agentrun continuation
This commit is contained in:
Lyon
2026-06-01 20:04:06 +08:00
committed by GitHub
7 changed files with 111 additions and 10 deletions
+11
View File
@@ -24,6 +24,11 @@ Cloud Workbench is the default user-facing frontend at
API/edge 访问、source reports、本地 echo、mock replies 或 provider
credential gaps 不能被写成真实 assistant replyreadiness 权威文档是
[code-agent-chat-readiness.md](code-agent-chat-readiness.md).
- Cloud Web 的 Code Agent 非视觉业务路径必须能被 `hwlab-cli client agent`
复现。Web 发现 AgentRun、session continuation、trace/result 或 provider
问题时,先用 CLI 通过同一 Cloud Web base URL、同一 `/v1/agent/chat`、同一
`conversationId/sessionId/threadId/retryOf` 复现,再进入前端状态机或样式修复;
不能用只覆盖首轮新会话的 CLI 验收替代 Web continuation 验收。
## Required Layout
@@ -150,6 +155,12 @@ 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`
恢复出的历史消息如果携带 `traceId`,前端必须先对 `/v1/agent/chat/result/<traceId>`
终态归一,再回放 `/trace`。旧的 `running` 消息不能只因为 localStorage restore 被降级成
`source` 后停留在“处理中”文本;如果后端 result 已经完成,消息卡片必须替换为真实
@@ -6,6 +6,7 @@
- 指挥官必须亲自掌握关键一手事实,不能只依赖 runner 的二手摘要。
- 必须亲自查看 `http://74.48.78.17:17666/` 默认首屏、`/gate` 或内部诊断页、关键 PR diff、部署 revision、DEV 验收结果和失败证据。
- 专题 issue 评论是长任务的上下文锚点。调查结论、细化方案、阶段进展、卡点、修复边界、验收命令和最终结论必须及时写入对应 GitHub issue 评论;不要只存在对话上下文、临时文件或本地记忆里。上下文压缩、换 agent、跨仓库修复或进入 CI/CD 前,应先读最新 issue 评论,再继续执行。
- 一层 Code Queue 直接执行业务实现、修复、部署和验证是允许的基层指挥模式。
- 不得把战略判断、是否偏离用户目标的判断、关键 PR 最终审查、关键部署证据审查、指挥官自我反省和指挥规则更新外派给 runner。
- 不得为了 Gate 生成 Gate、为了报告生成报告、为了审查再派审查、为了管理继续拆管理;Gate、报告、预检和观测脚本只能作为真实上线、真实 E2E 或 blocker 解除的必要最小工具。
+4 -1
View File
@@ -8,6 +8,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 问题后,必须先能用 `hwlab-cli client agent send/result/trace/inspect` 对同一 base URL、同一 `/v1/agent/chat`、同一 `conversationId/sessionId/threadId/retryOf` 复现或解释,再继续修 Web 状态机。
- 默认业务子命令不直连 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 内文件系统路径。
- `client runtime routes` 必须按当前运行 profile/lane 的数据生成 UniDesk `pod:` route;实现不得硬编码 `dev``v0.2``v0.3`、namespace 或 catalog path。新增版本只允许通过 `deploy.json.lanes[profile]` 声明 namespace、artifact catalog 和 service overrides,不为每个版本新增代码分支。
@@ -17,6 +18,8 @@
- 输出默认是 JSON;任何失败都要有 `ok:false``action``status`、HTTP 状态、route 和可定位错误,不允许无 stdout 成功。可能返回大对象的 `client` 子命令默认返回紧凑摘要,避免高频排障输出爆炸;需要完整响应体时显式加 `--full`
- `device-pod-cli`/`hwpod``job output` 默认也必须返回紧凑 JSON:保留 job/status/blocker/freshness/text/evidence 摘要,省略嵌套 gateway dispatch 和长命令;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head``grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。
- Code Agent 交互必须默认暴露 `traceId``resultUrl`、终态和 assistant 回复文本摘要;不能要求用户先拉全量 trace 再手工查找回复。
- `client agent send` 是 Cloud Web Code Agent composer 的非视觉等价入口。它必须支持 `--conversation-id``--session-id``--thread-id``--retry-of`,并在输出中返回 redacted continuation 摘要,证明本次 CLI 请求是否覆盖 Web 的继续会话路径。
- AgentRun v0.1 短连接 runner 不保证历史 Web/Codex `threadId` 可在新 runner Job 内 resumeCLI 仍应把 Web 提交的 `threadId` 原样送到 Cloud Web API,以便验证 adapter 是否正确把它降级为 `requestedThreadId` 元数据,而不是让 runner 执行旧 thread resume。
- `client harness``client harness-ops``client harness-opt` 吸收 G14 harness-ops 的短连接业务能力:health、submit、result、trace、wait 和 audit。
- harness 系列命令只调用 Cloud Web/Code Agent 同源 API,不创建镜像、Job、常驻服务,也不执行 hot-sync、kubectl cp 或硬编码 namespace/pod 的运行面写路径。
- 旧 `hwlab-cli cicd`、fixture MVP gate 和 CLI 镜像/Job 口径属于废弃路径;开发中遇到这些旧门禁、旧测试或旧预检时直接删除,不再维护兼容。
@@ -44,7 +47,7 @@
| `hwlab-cli client gateway sessions` | `GET Cloud API /v1/gateway/sessions` | 显式 Cloud API 诊断入口,观察 gateway online/stale、inflight 和 capability;默认不带 Web cookie。 |
| `hwlab-cli client gateway invoke` | `POST Cloud API /v1/rpc/hardware.invoke.shell` | 显式 Cloud API 诊断入口,执行一次 bounded shell dispatch 并返回结构化 dispatch 摘要。 |
| `hwlab-cli client gateway pressure` | `POST Cloud API /v1/rpc/hardware.invoke.shell` | 显式 Cloud API 压测入口,真实验证大输出、长单行、stderr、timeout 和并发超容量不会造成黑洞。 |
| `hwlab-cli client agent send` | `POST /v1/agent/chat` + `GET /result/{trace}` | 以 short connection 提交 Code Agent 消息并轮询结果,默认输出 assistant 回复文本摘要。 |
| `hwlab-cli client agent send` | `POST /v1/agent/chat` + `GET /result/{trace}` | 以 short connection 提交 Code Agent 消息并轮询结果,默认输出 assistant 回复文本摘要;支持 `--conversation-id``--session-id``--thread-id``--retry-of` 复现 Web continuation。 |
| `hwlab-cli client agent trace TRACE` | `GET /v1/agent/chat/trace/{trace}` | 回放 trace,默认输出状态、事件摘要和 assistant stream 文本。 |
| `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` 是同义别名。 |
@@ -19,6 +19,7 @@ const DEFAULT_REPO_URL = "http://git-mirror-http.devops-infra.svc.cluster.local/
const DEFAULT_RUNNER_NAMESPACE = "agentrun-v01";
const DEFAULT_TIMEOUT_MS = 1_200_000;
const AGENTRUN_BACKENDS = Object.freeze(["codex", "deepseek"]);
const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-new-runner-thread";
export function codeAgentAgentRunAdapterEnabled(env = process.env) {
const value = String(env.HWLAB_CODE_AGENT_ADAPTER ?? env.HWLAB_CODE_AGENT_PROVIDER ?? "").trim().toLowerCase();
@@ -46,6 +47,8 @@ export function initialAgentRunChatResult({ params = {}, options = {}, traceId }
capabilityLevel: "agentrun-v01-manual-dispatch",
sessionMode: "agentrun-v01-session-ref",
implementationType: "hwlab-cloud-api-agentrun-v01-adapter",
requestedThreadId: safeOpaqueId(params.threadId) || null,
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
agentRun: {
adapter: ADAPTER_ID,
status: "pending-dispatch",
@@ -323,11 +326,12 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile }) {
sessionRef: {
sessionId: agentRunSessionId(params, traceId),
...(safeConversationId(params.conversationId) ? { conversationId: safeConversationId(params.conversationId) } : {}),
...(safeOpaqueId(params.threadId) ? { threadId: safeOpaqueId(params.threadId) } : {}),
metadata: {
adapter: ADAPTER_ID,
hwlabTraceId: traceId,
hwlabApi: "/v1/agent/chat",
requestedThreadId: safeOpaqueId(params.threadId) || null,
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
valuesPrinted: false
}
},
@@ -364,7 +368,9 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile }) {
traceId,
conversationId: safeConversationId(params.conversationId) || null,
sessionId: agentRunSessionId(params, traceId),
threadId: safeOpaqueId(params.threadId) || null,
threadId: null,
requestedThreadId: safeOpaqueId(params.threadId) || null,
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
providerProfile: backendProfile,
source: "hwlab-cloud-api",
valuesPrinted: false
+13 -1
View File
@@ -154,11 +154,17 @@ 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.metadata.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(body.sessionRef.metadata.threadContinuityPolicy, "hwlab-agentrun-v01-new-runner-thread");
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") {
assert.equal(body.type, "turn");
assert.match(body.payload.prompt, /AgentRun adapter/u);
assert.equal(body.payload.threadId, null);
assert.equal(body.payload.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(body.payload.threadContinuityPolicy, "hwlab-agentrun-v01-new-runner-thread");
return send({ id: "cmd_hwlab_adapter", runId: "run_hwlab_adapter", state: "pending", type: "turn", seq: 1 });
}
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/runner-jobs") {
@@ -242,7 +248,13 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
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-agentrun", message: "AgentRun adapter smoke" })
body: JSON.stringify({
conversationId: "cnv_server-test-agentrun",
sessionId: "ses_server-test-agentrun",
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
retryOf: "trc_previous-agentrun-web",
message: "AgentRun adapter smoke"
})
});
assert.equal(submit.status, 202);
const accepted = await submit.json();
+49 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile } from "node:fs/promises";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "bun:test";
@@ -242,6 +242,54 @@ test("hwlab-cli client agent send submits async and polls result", async () => {
assert.equal(result.payload.result.body.reply.content, "hi");
});
test("hwlab-cli client agent send preserves Web continuation fields", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-agent-continuation-"));
await writeFile(path.join(cwd, "prompt.txt"), "看看有什么device-pod能用?", "utf8");
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"agent",
"send",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--message-file",
"prompt.txt",
"--trace-id",
"trc_web_continue",
"--conversation-id",
"cnv_web_continue",
"--session-id",
"ses_web_continue",
"--thread-id",
"019e8078-db67-7750-a5d9-1a99f3abd445",
"--retry-of",
"trc_previous",
"--no-wait"
], {
cwd,
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_web_continue", resultUrl: "/v1/agent/chat/result/trc_web_continue" }), { status: 202 });
},
stdinText: "fallback stdin",
sleep: async () => {}
});
assert.equal(result.exitCode, 0);
assert.equal(calls[0].url, "http://web.test/v1/agent/chat");
assert.equal(calls[0].body.message, "看看有什么device-pod能用?");
assert.equal(calls[0].body.conversationId, "cnv_web_continue");
assert.equal(calls[0].body.sessionId, "ses_web_continue");
assert.equal(calls[0].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(calls[0].body.retryOf, "trc_previous");
assert.equal(result.payload.continuation.webEquivalent, true);
assert.equal(result.payload.continuation.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(result.payload.continuation.retryOf, "trc_previous");
assert.equal(JSON.stringify(result.payload).includes("hwlab_session=session-a"), false);
});
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({
+25 -5
View File
@@ -94,7 +94,7 @@ function help() {
"hwlab-cli client workbench summary --pod-id device-pod-71-freq",
"hwlab-cli client request GET /v1/access/status [--full]",
"hwlab-cli client rpc system.health [--full]",
"hwlab-cli client agent send --message TEXT --provider-profile deepseek --timeout-ms 120000",
"hwlab-cli client agent send --message TEXT|--message-file PATH [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] --provider-profile deepseek --timeout-ms 120000",
"hwlab-cli client agent result TRACE_ID",
"hwlab-cli client agent trace TRACE_ID",
"hwlab-cli client agent inspect --trace-id TRACE_ID",
@@ -512,7 +512,7 @@ async function agentCommand(context: any) {
}
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) }), extraHeaders: { "x-trace-id": 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 } });
return responsePayload("client.agent.cancel", response, context, { route: route("POST", "/v1/agent/chat/cancel"), traceId });
}
throw cliError("unsupported_agent_command", `unsupported agent command: ${subcommand}`, { subcommand });
@@ -722,15 +722,22 @@ async function harnessAudit(context: any) {
async function agentSend(context: any) {
const { parsed } = context;
const message = text(parsed.message ?? parsed.text) || text(context.stdinText);
const message = await messageValue(context);
if (!message) throw cliError("message_required", "client agent send requires --message or stdin text");
const traceId = text(parsed.traceId) || makeId("trc");
const conversationId = text(parsed.conversationId) || makeId("cnv");
const continuation = agentContinuationSummary({
conversationId,
sessionId: text(parsed.sessionId),
threadId: text(parsed.threadId),
retryOf: text(parsed.retryOf)
});
const requestBody = clean({
message,
conversationId,
sessionId: text(parsed.sessionId),
threadId: text(parsed.threadId),
retryOf: text(parsed.retryOf),
traceId,
providerProfile: text(parsed.providerProfile) || "deepseek",
gatewayShellTimeoutMs: numberOption(parsed.gatewayShellTimeoutMs),
@@ -750,10 +757,10 @@ async function agentSend(context: any) {
}
});
if (!isHttpSuccess(accepted)) {
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId });
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation });
}
if (parsed.noWait === true) {
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, waited: false });
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, waited: false });
}
const result = await pollAgentResult(context, traceId, accepted.body);
return ok("client.agent.send", {
@@ -761,6 +768,7 @@ async function agentSend(context: any) {
route: route("POST", "/v1/agent/chat"),
traceId,
conversationId,
continuation,
accepted: compactBody(accepted.body),
result: result.response ? compactResponse(result.response) : null,
polls: result.polls,
@@ -1281,6 +1289,18 @@ function compactResponse(response: any) {
return { httpStatus: response.status, route: route(response.method, response.path), body: compactApiBody(response.body) };
}
function agentContinuationSummary(value: { conversationId?: string | null; sessionId?: string | null; threadId?: string | null; retryOf?: string | null }) {
return pruneUndefined({
webEquivalent: true,
shortConnection: true,
conversationId: text(value.conversationId),
sessionId: text(value.sessionId),
threadId: text(value.threadId),
retryOf: text(value.retryOf),
valuesPrinted: false
});
}
function responseBodyForCli(body: any, parsed: ParsedArgs) {
return parsed.full === true ? body : compactApiBody(body);
}