Merge remote-tracking branch 'origin/v0.2' into fix/v02-context-completed-693
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
- 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`。
|
||||
- `client agent composer status|submit` 是 Cloud Web composer 的状态机等价入口。`status` 必须先恢复账号 workspace,再用与 Web 相同的 composer policy 输出 `locked`、`disabled`、`submitMode`、`route`、`targetTraceId`、conversation/session/thread 和 workspace revision;运行中 turn 必须显示 `locked=false`、`disabled=false`、`submitMode=steer`。`submit` 必须按该 policy 自动选择 `/v1/agent/chat` 或 `/v1/agent/chat/steer`,不得要求用户手动判断 URL 或绕过 Cloud Web 同源 path。
|
||||
- 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 接收和应用。
|
||||
@@ -58,6 +59,7 @@
|
||||
| `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` | `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 composer status|submit` | `GET /v1/workbench/workspace` + `POST /v1/agent/chat` 或 `POST /v1/agent/chat/steer` | 使用 Web 共享 composer policy 探测输入框锁定状态和 turn/steer 分流;运行中应显示无锁 steer,并自动提交 steer。 |
|
||||
| `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 请求。 |
|
||||
@@ -105,6 +107,10 @@
|
||||
|
||||
阅读 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,也不能用自动交互脚本。
|
||||
|
||||
## T3.4
|
||||
|
||||
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:先启动真实运行中的 Code Agent turn,再运行 `client agent composer status`,确认 `composer.locked=false`、`composer.disabled=false`、`composer.submitMode=steer`、`composer.route=/v1/agent/chat/steer`、`composer.targetTraceId=<运行中 trace>`;随后运行 `client agent composer submit --message "请在最终回复或 trace 中体现 COMPOSER_STEER_OK"`,确认请求自动走 Cloud Web 同源 `/v1/agent/chat/steer`,最后用原 trace 的 `client agent result` 或 `client agent trace --render web` 验证 steer 可见。该验收必须使用 runtime namespace/lane 自动解析入口,不能手动传 URL、不能 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 会被拒绝。
|
||||
@@ -127,6 +133,7 @@
|
||||
| --- | --- | --- |
|
||||
| 固定 repo 短连接 client | 目标状态 | `hwlab-cli` 在 `G14:/root/hwlab-v02` 或当前 v0.2 worktree 直接用 Bun 运行,不作为 runtime service。 |
|
||||
| WEB 等价 API client | 目标状态 | `client` 子命令覆盖 Cloud Web 非视觉业务面。 |
|
||||
| WEB composer 状态机等价 | 已实现 | `client agent composer status|submit` 复用 Web composer policy,可探测输入框无锁和自动 steer/turn 分流。 |
|
||||
| JSON-RPC 同源 API | 目标状态 | `client rpc` 自动补齐 Web JSON-RPC envelope 的 `meta` 字段。 |
|
||||
| 通用同源 API request | 目标状态 | `client request` 用于追平低频和新增 WEB API,禁止绝对 URL。 |
|
||||
| G14 harness-ops 短连接能力 | 目标状态 | `client harness` / `client harness-ops` / `client harness-opt` 覆盖 submit/result/trace/wait/audit,只作为业务 API client。 |
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- Cloud Web 与 `hwlab-cli client` 必须共享同一组非视觉业务 API。浏览器遇到的 Code Agent continuation、trace/result、device-pod list/status 和 device-pod job 问题,必须能通过 `hwlab-cli client` 走同一 `19666` Cloud Web path 复现;不能让 CLI 长期绕到 `19667` Cloud API 后把 Web 路径缺口误判为业务已通过。
|
||||
- 浏览器启动后必须从 `GET /v1/workbench/workspace` hydrate 账号 workspace;同一个账号在多个浏览器标签页、多个浏览器或 CLI profile 中应看到同一个 `workspaceId`、selected conversation/session/thread、provider profile 和 active trace。浏览器 localStorage 只能作为短期缓存,并必须绑定 actor,不能作为 workspace authority。
|
||||
- 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`/下载排障。
|
||||
- Cloud Web Code Agent composer 必须无锁:运行中 turn 不得把输入框或发送按钮 disabled。浏览器提交时必须按共享 composer policy 自动分流,空闲/终态走 `POST /v1/agent/chat` 开新 turn,存在 active running trace 时走 `POST /v1/agent/chat/steer` 引导当前 turn。`hwlab-cli client agent composer status` 必须能用同一 policy 输出 `locked=false`、`disabled=false`、`submitMode=turn|steer`、`route` 和 `targetTraceId`,用于复现 Web 输入框是否被旧逻辑锁住。
|
||||
- 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。
|
||||
@@ -55,6 +56,8 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验,
|
||||
|
||||
阅读 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 的结构化业务状态。
|
||||
|
||||
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:先启动一个真实运行中的 Code Agent turn,再运行 `hwlab-cli client agent composer status`,确认输出 `composer.locked=false`、`composer.disabled=false`、`composer.submitMode=steer`、`composer.route=/v1/agent/chat/steer` 和当前 `targetTraceId`;随后运行 `hwlab-cli client agent composer submit --message ...`,确认 CLI 按 Web composer policy 自动走 steer,而不是手动指定 steer URL 或新开 turn。
|
||||
|
||||
## 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 固定复现再修实现。
|
||||
@@ -90,6 +93,7 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验,
|
||||
| Workbench 首屏 | 已实现 | 当前页面直接进入工作台,不是 landing page。 |
|
||||
| cloud-api 同源代理 | 已实现 | 受 route policy 控制;device-pod job POST 必须与 Cloud API route policy 对齐。 |
|
||||
| Code Agent UI/trace/result | 已实现 | 支持 provider profile、timeout、trace 轮询和取消。 |
|
||||
| Code Agent 无锁 composer | 已实现 | Web/CLI 共享 composer policy;运行中输入框保持可编辑并自动走 steer。 |
|
||||
| 账号 workspace hydrate/sync | 已实现 | 启动读取 `account_workspaces`,Code Agent 请求携带 workspace revision,终态再同步 workspace。 |
|
||||
| device-pod 面板 | 未完全实现 | 当前主要消费 fake/只读 device-pod payload。 |
|
||||
| 完整多用户 admin/user UI | 未完全实现 | 登录态存在,权限 authority 仍需按 spec-user-access 收敛到 cloud-api。 |
|
||||
|
||||
@@ -1150,20 +1150,21 @@ function mapAgentRunEvent(event, mapping = {}) {
|
||||
|
||||
function agentRunCommandExecutionEvent(base, payload = {}) {
|
||||
const item = payload.item && typeof payload.item === "object" ? payload.item : null;
|
||||
if (item?.type !== "commandExecution") return null;
|
||||
const payloadToolType = firstNonEmpty(payload.toolName, payload.type, payload.name);
|
||||
if (item?.type !== "commandExecution" && payloadToolType !== "commandExecution") return null;
|
||||
const method = String(payload.method ?? "");
|
||||
const completed = method === "item/completed" || item.status === "completed";
|
||||
const output = firstNonEmpty(item.aggregatedOutput, item.stdout, item.output, payload.summary?.text, payload.itemPreview);
|
||||
const completed = method === "item/completed" || payload.status === "completed" || item?.status === "completed";
|
||||
const output = firstNonEmpty(item?.aggregatedOutput, item?.stdout, item?.output, payload.outputSummary, payload.stdoutSummary, payload.summary?.text, payload.itemPreview);
|
||||
return {
|
||||
...base,
|
||||
type: "tool_call",
|
||||
status: completed ? "completed" : "started",
|
||||
label: completed ? "item/commandExecution:completed" : "item/commandExecution:started",
|
||||
toolName: "commandExecution",
|
||||
itemId: item.id ?? null,
|
||||
command: item.command ?? item.commandLine ?? null,
|
||||
exitCode: Number.isInteger(item.exitCode) ? item.exitCode : undefined,
|
||||
durationMs: typeof item.durationMs === "number" ? item.durationMs : undefined,
|
||||
itemId: item?.id ?? payload.itemId ?? null,
|
||||
command: firstNonEmpty(item?.command, item?.commandLine, payload.command, payload.commandLine),
|
||||
exitCode: Number.isInteger(item?.exitCode) ? item.exitCode : Number.isInteger(payload.exitCode) ? payload.exitCode : undefined,
|
||||
durationMs: typeof item?.durationMs === "number" ? item.durationMs : typeof payload.durationMs === "number" ? payload.durationMs : undefined,
|
||||
outputBytes: typeof payload.outputBytes === "number" ? payload.outputBytes : payload.summary?.outputBytes,
|
||||
stdoutSummary: output,
|
||||
outputSummary: output,
|
||||
|
||||
@@ -267,7 +267,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
|
||||
] });
|
||||
return send({ items: [
|
||||
{ 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_tool", runId: "run_hwlab_adapter", seq: 2, type: "tool_call", payload: { method: "item/completed", type: "commandExecution", toolName: "commandExecution", itemId: "call_agentrun_tool", command: "/bin/sh -lc 'hwpod profile list'", status: "completed", exitCode: 0, durationMs: 708, outputSummary: '{"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: "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" }
|
||||
@@ -333,7 +333,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
|
||||
accessController: {
|
||||
required: false,
|
||||
async authenticate() {
|
||||
return { ok: true, actor: null, session: null };
|
||||
return { ok: true, actor: { id: "usr_agent_owner", username: "agent-owner", displayName: "Agent Owner", role: "user", status: "active" }, session: { id: "auth_ses_agent_owner" } };
|
||||
},
|
||||
async createCodeAgentDevicePodSession() {
|
||||
return { token: "test-device-pod-session-token" };
|
||||
|
||||
@@ -139,11 +139,10 @@ async function hydrateCodeAgentConversationContext({ params = {}, options = {},
|
||||
if (workspaceMessages.length > 0) {
|
||||
return attachCodeAgentConversationContext(params, workspaceMessages, "workbench-workspace", traceId, options);
|
||||
}
|
||||
const actor = conversationContextActor(params, options);
|
||||
const conversationId = safeConversationId(params.conversationId ?? workspaceClaim?.workspace?.selectedConversationId);
|
||||
if (!actor || !conversationId || typeof options.accessController?.visibleConversationForActor !== "function") return params;
|
||||
if (!options.actor || !conversationId || typeof options.accessController?.visibleConversationForActor !== "function") return params;
|
||||
try {
|
||||
const conversation = await options.accessController.visibleConversationForActor(actor, conversationId, params.projectId ?? workspaceClaim?.workspace?.projectId);
|
||||
const conversation = await options.accessController.visibleConversationForActor(options.actor, conversationId, params.projectId ?? workspaceClaim?.workspace?.projectId);
|
||||
const messages = Array.isArray(conversation?.messages) ? conversation.messages : Array.isArray(conversation?.snapshot?.messages) ? conversation.snapshot.messages : [];
|
||||
return messages.length > 0 ? attachCodeAgentConversationContext(params, messages, "account-conversation", traceId, options) : params;
|
||||
} catch (error) {
|
||||
@@ -208,13 +207,6 @@ function publicConversationContextMessage(message) {
|
||||
};
|
||||
}
|
||||
|
||||
function conversationContextActor(params = {}, options = {}) {
|
||||
if (options.actor) return options.actor;
|
||||
const ownerUserId = textValue(params.ownerUserId);
|
||||
if (!ownerUserId) return null;
|
||||
return { id: ownerUserId, role: textValue(params.ownerRole) || "user" };
|
||||
}
|
||||
|
||||
async function codeAgentChatExecutionOptions(options = {}, params = {}) {
|
||||
const env = codeAgentProviderProfileEnv(options.env ?? process.env, params);
|
||||
Object.assign(env, await codeAgentDevicePodAuthEnv(options, env));
|
||||
|
||||
@@ -2439,6 +2439,7 @@ function hasCodeAgentStatusSummaryContract({ html, app, styles, codeAgentStatus
|
||||
|
||||
function hasCodeAgentConversationUxStates({ app, styles }) {
|
||||
const submitBody = `${functionBody(app, "initCommandBar")}\n${functionBody(app, "submitAgentMessage")}`;
|
||||
const renderStatusBody = functionBody(app, "renderAgentChatStatus");
|
||||
const applyResultBody = functionBody(app, "applyCodeAgentResultToMessage");
|
||||
const agentToneBody = functionBody(app, "agentStatusTone");
|
||||
const statusToneContract =
|
||||
@@ -2464,6 +2465,13 @@ function hasCodeAgentConversationUxStates({ app, styles }) {
|
||||
/DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*1800000/u.test(app) &&
|
||||
/MAX_CODE_AGENT_TIMEOUT_MS\s*=\s*2400000/u.test(app) &&
|
||||
/timeoutMs:\s*CODE_AGENT_SUBMIT_TIMEOUT_MS/u.test(app) &&
|
||||
/computeCodeAgentComposerState/u.test(app) &&
|
||||
/composer\.submitMode\s*===\s*"steer"/u.test(submitBody) &&
|
||||
/sendAgentSteer/u.test(app) &&
|
||||
/fetchJson\("\/v1\/agent\/chat\/steer"/u.test(functionBody(app, "sendAgentSteer")) &&
|
||||
/el\.commandInput\.disabled\s*=\s*composer\.disabled/u.test(renderStatusBody) &&
|
||||
/el\.commandSend\.textContent\s*=\s*composer\.submitMode\s*===\s*"steer"\s*\?\s*"引导"\s*:\s*"发送"/u.test(renderStatusBody) &&
|
||||
!/el\.commandInput\.disabled\s*=\s*status\s*===\s*"running"/u.test(renderStatusBody) &&
|
||||
/HWLAB_CLOUD_WEB_CONFIG/u.test(app) &&
|
||||
/正在处理这次 Code Agent 请求/u.test(app) &&
|
||||
/旧 4500ms/u.test(app) &&
|
||||
|
||||
@@ -559,6 +559,114 @@ test("hwlab-cli client agent steer posts to Web-equivalent steer route", async (
|
||||
assert.equal(result.payload.waitPolicy.webEquivalent, true);
|
||||
});
|
||||
|
||||
test("hwlab-cli agent composer status detects Web unlocked steer mode", async () => {
|
||||
const calls: any[] = [];
|
||||
const result = await runHwlabCli([
|
||||
"client",
|
||||
"agent",
|
||||
"composer",
|
||||
"status",
|
||||
"--base-url",
|
||||
"http://web.test",
|
||||
"--cookie",
|
||||
"hwlab_session=session-a"
|
||||
], {
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
workspace: {
|
||||
workspaceId: "wsp_composer",
|
||||
revision: 9,
|
||||
selectedConversationId: "cnv_composer",
|
||||
selectedAgentSessionId: "ses_composer",
|
||||
workspace: {
|
||||
activeTraceId: "trc_composer_active",
|
||||
threadId: "thread-composer",
|
||||
sessionStatus: "running",
|
||||
messages: [{ role: "agent", status: "running", traceId: "trc_composer_active" }]
|
||||
}
|
||||
}
|
||||
}), { status: 200 });
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench");
|
||||
assert.equal(result.payload.action, "client.agent.composer.status");
|
||||
assert.equal(result.payload.composer.webEquivalent, true);
|
||||
assert.equal(result.payload.composer.locked, false);
|
||||
assert.equal(result.payload.composer.disabled, false);
|
||||
assert.equal(result.payload.composer.submitMode, "steer");
|
||||
assert.equal(result.payload.composer.route, "/v1/agent/chat/steer");
|
||||
assert.equal(result.payload.composer.targetTraceId, "trc_composer_active");
|
||||
assert.equal(result.payload.route.path, "/v1/agent/chat/steer");
|
||||
});
|
||||
|
||||
test("hwlab-cli agent composer submit auto routes active Web turn to steer", async () => {
|
||||
const calls: any[] = [];
|
||||
const result = await runHwlabCli([
|
||||
"client",
|
||||
"agent",
|
||||
"composer",
|
||||
"submit",
|
||||
"--base-url",
|
||||
"http://web.test",
|
||||
"--cookie",
|
||||
"hwlab_session=session-a",
|
||||
"--message",
|
||||
"please steer from composer",
|
||||
"--steer-trace-id",
|
||||
"trc_steer_composer"
|
||||
], {
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
|
||||
if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_device_pod_workbench")) {
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
workspace: {
|
||||
workspaceId: "wsp_composer",
|
||||
revision: 9,
|
||||
selectedConversationId: "cnv_composer",
|
||||
selectedAgentSessionId: "ses_composer",
|
||||
workspace: {
|
||||
activeTraceId: "trc_composer_active",
|
||||
threadId: "thread-composer",
|
||||
sessionStatus: "running",
|
||||
messages: [{ role: "agent", status: "running", traceId: "trc_composer_active" }]
|
||||
}
|
||||
}
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
accepted: true,
|
||||
status: "running",
|
||||
traceId: "trc_composer_active",
|
||||
steerTraceId: "trc_steer_composer",
|
||||
agentRun: { steerCommandId: "cmd_composer_steer" }
|
||||
}), { status: 202 });
|
||||
},
|
||||
sleep: async () => {}
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(calls[1].url, "http://web.test/v1/agent/chat/steer");
|
||||
assert.equal(calls[1].init.headers["x-trace-id"], "trc_composer_active");
|
||||
assert.equal(calls[1].body.traceId, "trc_composer_active");
|
||||
assert.equal(calls[1].body.targetTraceId, "trc_composer_active");
|
||||
assert.equal(calls[1].body.steerTraceId, "trc_steer_composer");
|
||||
assert.equal(calls[1].body.message, "please steer from composer");
|
||||
assert.equal(calls[1].body.conversationId, "cnv_composer");
|
||||
assert.equal(calls[1].body.sessionId, "ses_composer");
|
||||
assert.equal(calls[1].body.threadId, "thread-composer");
|
||||
assert.equal(result.payload.action, "client.agent.composer.submit");
|
||||
assert.equal(result.payload.composer.locked, false);
|
||||
assert.equal(result.payload.composer.submitMode, "steer");
|
||||
assert.equal(result.payload.route.path, "/v1/agent/chat/steer");
|
||||
assert.equal(result.payload.body.agentRun.steerCommandId, "cmd_composer_steer");
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -606,19 +714,24 @@ test("hwlab-cli Web trace render reports suppressed noise and command tool detai
|
||||
{ traceId: "trc_render_agentrun", seq: 1, label: "agentrun:backend:command-created", status: "running", type: "backend", createdAt: "2026-06-01T13:00:00.000Z" },
|
||||
{ traceId: "trc_render_agentrun", seq: 2, label: "agentrun:backend:thread/status/changed", status: "running", type: "backend", createdAt: "2026-06-01T13:00:01.000Z" },
|
||||
{ traceId: "trc_render_agentrun", seq: 3, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", itemId: "call_1", command: "/bin/sh -lc 'hwpod profile list'", exitCode: 0, stdoutSummary: '{"ok":true,"action":"profile.list"}', createdAt: "2026-06-01T13:00:02.000Z" },
|
||||
{ traceId: "trc_render_agentrun", seq: 4, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "当前可见 device-pod。", createdAt: "2026-06-01T13:00:03.000Z" }
|
||||
{ traceId: "trc_render_agentrun", seq: 4, label: "agentrun:tool:item/started", type: "tool_call", toolName: "commandExecution", status: "started", itemId: "call_2", command: "/bin/sh -lc 'printf TOOL_RENDER_OK'", createdAt: "2026-06-01T13:00:02.100Z" },
|
||||
{ traceId: "trc_render_agentrun", seq: 5, label: "agentrun:tool:item/completed", type: "tool_call", toolName: "commandExecution", status: "completed", itemId: "call_2", command: "/bin/sh -lc 'printf TOOL_RENDER_OK'", exitCode: 0, stdoutSummary: "TOOL_RENDER_OK", outputBytes: 14, createdAt: "2026-06-01T13:00:02.200Z" },
|
||||
{ traceId: "trc_render_agentrun", seq: 6, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "当前可见 device-pod。", createdAt: "2026-06-01T13:00:03.000Z" }
|
||||
]
|
||||
}), { status: 200 })
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.payload.body.render, "web");
|
||||
assert.equal(result.payload.body.sourceEventCount, 4);
|
||||
assert.equal(result.payload.body.sourceEventCount, 6);
|
||||
assert.equal(result.payload.body.noiseEventCount, 2);
|
||||
const text = JSON.stringify(result.payload.body.rows);
|
||||
assert.equal(text.includes("thread/status/changed"), false);
|
||||
assert.match(text, /hwpod profile list/u);
|
||||
assert.match(text, /profile\.list/u);
|
||||
assert.match(text, /TOOL_RENDER_OK/u);
|
||||
assert.equal(text.includes("toolName=item/started"), false);
|
||||
assert.equal(text.includes("toolName=item/completed"), false);
|
||||
assert.match(text, /当前可见 device-pod/u);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { computeCodeAgentComposerState } from "../../web/hwlab-cloud-web/composer-policy.ts";
|
||||
import { traceDisplayRows, traceNoiseEventCount } from "../../web/hwlab-cloud-web/app-trace.ts";
|
||||
import { resolveRuntimeEndpoint, runtimeEndpointVisibility, sameRuntimeEndpointScope } from "./runtime-endpoint-resolver.ts";
|
||||
|
||||
@@ -586,6 +587,7 @@ async function agentCommand(context: any) {
|
||||
const subcommand = context.rest[0] || "send";
|
||||
if (wantsHelp(context)) return agentHelp();
|
||||
if (subcommand === "send") return agentSend(context);
|
||||
if (subcommand === "composer") return agentComposer(context);
|
||||
if (subcommand === "result") {
|
||||
const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId);
|
||||
const pathName = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
|
||||
@@ -619,6 +621,8 @@ function agentHelp() {
|
||||
imagePublished: false,
|
||||
commands: [
|
||||
"send --message TEXT|--message-file PATH [--from-trace TRACE] [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] [default: short return]",
|
||||
"composer status [--profile NAME] [--full]",
|
||||
"composer submit --message TEXT|--message-file PATH [--profile NAME] [default: auto turn/steer]",
|
||||
"send --message TEXT --wait [--timeout-ms N] [--poll-interval-ms N]",
|
||||
"result TRACE_ID [--full]",
|
||||
"trace TRACE_ID [--render web] [--limit N] [--full]",
|
||||
@@ -629,6 +633,110 @@ function agentHelp() {
|
||||
});
|
||||
}
|
||||
|
||||
async function agentComposer(context: any) {
|
||||
const subcommand = context.rest[1] || "status";
|
||||
if (subcommand === "status") return agentComposerStatus(context);
|
||||
if (subcommand === "submit" || subcommand === "send") return agentComposerSubmit(context);
|
||||
throw cliError("unsupported_agent_composer_command", `unsupported agent composer command: ${subcommand}`, { subcommand });
|
||||
}
|
||||
|
||||
async function agentComposerStatus(context: any) {
|
||||
const workspaceState = await restoreWorkbenchWorkspace(context, { quiet: true });
|
||||
const localState = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() });
|
||||
const composer = computeCliComposerState(context, workspaceState ?? workspaceSummaryFromSession(localState));
|
||||
return ok("client.agent.composer.status", {
|
||||
baseUrl: baseUrl(context.parsed, context.env),
|
||||
runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "web")),
|
||||
status: "succeeded",
|
||||
route: route("POST", composer.route),
|
||||
composer,
|
||||
workspace: workspaceState,
|
||||
workspaceRestore: workspaceState ? "succeeded" : "unavailable",
|
||||
nextCommands: composer.submitMode === "steer"
|
||||
? [`hwlab-cli client agent composer submit --message TEXT`, `hwlab-cli client agent trace ${composer.targetTraceId} --render web`]
|
||||
: ["hwlab-cli client agent composer submit --message TEXT"]
|
||||
});
|
||||
}
|
||||
|
||||
async function agentComposerSubmit(context: any) {
|
||||
const message = await messageValue(context);
|
||||
if (!message) throw cliError("message_required", "client agent composer submit requires --message or stdin text");
|
||||
const workspaceState = context.parsed.noWorkspace === true ? null : await restoreWorkbenchWorkspace(context, { quiet: true });
|
||||
const localState = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() });
|
||||
const composer = computeCliComposerState(context, workspaceState ?? workspaceSummaryFromSession(localState));
|
||||
if (composer.submitMode === "steer") {
|
||||
const traceId = requiredTraceId(composer.targetTraceId ?? context.parsed.traceId ?? context.parsed.targetTraceId);
|
||||
const steerTraceId = text(context.parsed.steerTraceId) || makeId("trc_steer");
|
||||
const body = clean({
|
||||
traceId,
|
||||
targetTraceId: traceId,
|
||||
steerTraceId,
|
||||
message,
|
||||
conversationId: text(context.parsed.conversationId) || composer.conversationId,
|
||||
sessionId: text(context.parsed.sessionId) || composer.sessionId,
|
||||
threadId: text(context.parsed.threadId) || composer.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.composer.submit", response, context, {
|
||||
route: route("POST", "/v1/agent/chat/steer"),
|
||||
traceId,
|
||||
steerTraceId,
|
||||
composer,
|
||||
workspace: workspaceSummaryFromSession(await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })),
|
||||
body: responseBodyForCli(response.body, context.parsed),
|
||||
waitPolicy: {
|
||||
defaultWait: false,
|
||||
webEquivalent: true,
|
||||
reason: "Composer detected an active Web turn and submitted steer through the same Cloud Web path.",
|
||||
nextCommands: [
|
||||
`hwlab-cli client agent result ${traceId}`,
|
||||
`hwlab-cli client agent trace ${traceId} --render web`
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
const sendContext = {
|
||||
...context,
|
||||
parsed: {
|
||||
...context.parsed,
|
||||
message,
|
||||
conversationId: text(context.parsed.conversationId) || composer.conversationId,
|
||||
sessionId: text(context.parsed.sessionId) || composer.sessionId,
|
||||
threadId: text(context.parsed.threadId) || composer.threadId,
|
||||
workspaceId: text(context.parsed.workspaceId) || composer.workspaceId,
|
||||
expectedWorkspaceRevision: numberOption(context.parsed.expectedWorkspaceRevision) ?? composer.workspaceRevision
|
||||
}
|
||||
};
|
||||
const result = await agentSend(sendContext);
|
||||
return { ...result, action: "client.agent.composer.submit", composer };
|
||||
}
|
||||
|
||||
function computeCliComposerState(context: any, workspaceState: any) {
|
||||
const state = workspaceState;
|
||||
return computeCodeAgentComposerState({
|
||||
workspace: state,
|
||||
conversationId: text(context.parsed.conversationId) || text(state?.selectedConversationId),
|
||||
sessionId: text(context.parsed.sessionId) || text(state?.selectedAgentSessionId),
|
||||
threadId: text(context.parsed.threadId) || text(state?.threadId),
|
||||
sessionStatus: text(context.parsed.sessionStatus) || text(state?.sessionStatus),
|
||||
targetTraceId: text(context.parsed.traceId ?? context.parsed.targetTraceId) || text(state?.activeTraceId),
|
||||
workspaceId: text(context.parsed.workspaceId) || text(state?.workspaceId),
|
||||
workspaceRevision: numberOption(context.parsed.expectedWorkspaceRevision) ?? state?.revision,
|
||||
messages: Array.isArray(state?.messages) ? state.messages : undefined
|
||||
});
|
||||
}
|
||||
|
||||
async function agentSteer(context: any) {
|
||||
const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId ?? context.parsed.targetTraceId);
|
||||
const message = await messageValue(context);
|
||||
@@ -1715,6 +1823,7 @@ function normalizeWorkbenchWorkspace(workspace: any) {
|
||||
threadId: text(workspaceJson.threadId),
|
||||
updatedAt: text(workspace.updatedAt),
|
||||
updatedByClient: text(workspace.updatedByClient),
|
||||
messages: Array.isArray(workspaceJson.messages) ? workspaceJson.messages : undefined,
|
||||
selectedConversation: workspace.selectedConversation ? compactApiBody(workspace.selectedConversation) : undefined,
|
||||
valuesRedacted: workspace.valuesRedacted === true,
|
||||
secretMaterialStored: workspace.secretMaterialStored === true
|
||||
|
||||
@@ -402,9 +402,15 @@ function renderAgentChatStatus(status, result = null) {
|
||||
};
|
||||
el.agentChatStatus.textContent = agentStatusLabel(status, result, labels);
|
||||
el.agentChatStatus.className = `state-tag tone-${toneClass(agentStatusTone(status, result))}`;
|
||||
el.commandInput.disabled = status === "running";
|
||||
el.commandSend.disabled = status === "running";
|
||||
el.commandSend.textContent = status === "running" ? "发送中" : "发送";
|
||||
const composer = currentComposerState({ status });
|
||||
el.commandInput.disabled = composer.disabled;
|
||||
el.commandSend.disabled = composer.disabled;
|
||||
el.commandSend.textContent = composer.submitMode === "steer" ? "引导" : "发送";
|
||||
el.commandSend.title = composer.submitMode === "steer"
|
||||
? `发送到运行中的 Code Agent turn:${composer.targetTraceId ?? "unknown"}`
|
||||
: "发送新的 Code Agent turn";
|
||||
el.commandInput.dataset.submitMode = composer.submitMode;
|
||||
el.commandSend.dataset.submitMode = composer.submitMode;
|
||||
if (result?.conversationId) {
|
||||
el.agentChatStatus.title = `${result.provider ?? "provider"} / ${result.model ?? "model"} / ${result.backend ?? "backend"}`;
|
||||
} else {
|
||||
@@ -412,6 +418,22 @@ function renderAgentChatStatus(status, result = null) {
|
||||
}
|
||||
}
|
||||
|
||||
function currentComposerState(overrides = {}) {
|
||||
return computeCodeAgentComposerState({
|
||||
...overrides,
|
||||
chatPending: state.chatPending,
|
||||
currentRequest: state.currentRequest,
|
||||
messages: state.chatMessages,
|
||||
conversationId: state.conversationId,
|
||||
sessionId: state.sessionId,
|
||||
threadId: state.threadId,
|
||||
activeTraceId: state.activeTraceId,
|
||||
sessionStatus: state.sessionStatus,
|
||||
workspaceId: state.workspaceId,
|
||||
workspaceRevision: state.workspaceRevision
|
||||
});
|
||||
}
|
||||
|
||||
function renderCodeAgentSummary() {
|
||||
const summary = currentCodeAgentStatusSummary();
|
||||
el.codeAgentSummary.className = `code-agent-summary tone-border-${toneClass(summary.tone)}`;
|
||||
@@ -986,6 +1008,7 @@ async function cancelAgentMessage(messageId) {
|
||||
stopRunningTraceStream(traceId);
|
||||
state.chatPending = false;
|
||||
state.currentRequest = null;
|
||||
if (state.activeTraceId === traceId) state.activeTraceId = null;
|
||||
state.sessionId = payload.sessionId || sessionId || state.sessionId;
|
||||
state.sessionStatus = payload.session?.status ?? runnerTrace?.sessionStatus ?? "canceled";
|
||||
state.chatMessages[index] = {
|
||||
|
||||
@@ -43,6 +43,7 @@ function initDevicePodPanel() {
|
||||
function initCommandBar() {
|
||||
syncCommandInputHeight();
|
||||
el.commandInput.addEventListener("input", syncCommandInputHeight);
|
||||
el.commandInput.addEventListener("input", () => renderAgentChatStatus(deriveAgentChatStatus(), latestChatResult()));
|
||||
el.commandInput.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
|
||||
event.preventDefault();
|
||||
@@ -98,7 +99,13 @@ function syncCommandInputHeight() {
|
||||
}
|
||||
|
||||
async function submitAgentMessage(value, options = {}) {
|
||||
if (!value || state.chatPending) return;
|
||||
if (!value) return;
|
||||
const composer = currentComposerState();
|
||||
if (composer.submitMode === "steer") {
|
||||
await submitAgentSteer(value, { ...options, composer });
|
||||
return;
|
||||
}
|
||||
if (state.chatPending) return;
|
||||
const traceId = options.traceId ?? nextProtocolId("trc");
|
||||
const activeConversationId = options.conversationId ?? state.conversationId ?? nextProtocolId("cnv");
|
||||
const requestedSessionId = options.sessionId === undefined ? sessionIdForNextRequest() : options.sessionId;
|
||||
@@ -140,6 +147,7 @@ async function submitAgentMessage(value, options = {}) {
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
state.chatMessages.push(userMessage, pendingMessage);
|
||||
state.activeTraceId = traceId;
|
||||
state.chatPending = true;
|
||||
state.currentRequest = {
|
||||
traceId,
|
||||
@@ -232,6 +240,7 @@ async function submitAgentMessage(value, options = {}) {
|
||||
} finally {
|
||||
if (state.currentRequest?.traceId === traceId) {
|
||||
state.currentRequest = null;
|
||||
state.activeTraceId = null;
|
||||
state.chatPending = false;
|
||||
} else if (!state.currentRequest) {
|
||||
state.chatPending = false;
|
||||
@@ -244,6 +253,76 @@ async function submitAgentMessage(value, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAgentSteer(value, options = {}) {
|
||||
const composer = options.composer ?? currentComposerState();
|
||||
const targetTraceId = composer.targetTraceId;
|
||||
if (!targetTraceId) return;
|
||||
const activeConversationId = options.conversationId ?? composer.conversationId ?? state.conversationId;
|
||||
const sessionId = options.sessionId === undefined ? composer.sessionId ?? state.sessionId : options.sessionId;
|
||||
const threadId = options.threadId === undefined ? composer.threadId ?? state.threadId : options.threadId;
|
||||
const steerTraceId = options.steerTraceId ?? nextProtocolId("trc_steer");
|
||||
const userMessage = {
|
||||
id: nextProtocolId("msg"),
|
||||
role: "user",
|
||||
title: `引导 ${shortTime(new Date().toISOString())}`,
|
||||
text: value,
|
||||
status: "sent",
|
||||
traceId: targetTraceId,
|
||||
steerTraceId,
|
||||
conversationId: activeConversationId,
|
||||
sessionId,
|
||||
threadId,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
state.chatMessages.push(userMessage);
|
||||
el.commandInput.value = "";
|
||||
syncCommandInputHeight();
|
||||
renderAgentChatStatus("running", latestChatResult());
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
renderDrafts();
|
||||
renderDevicePodPanel(state.liveSurface);
|
||||
try {
|
||||
const result = await sendAgentSteer(value, targetTraceId, steerTraceId, activeConversationId, sessionId, threadId);
|
||||
annotateSteerResult(targetTraceId, result, { steerTraceId, value });
|
||||
} catch (error) {
|
||||
annotateSteerFailure(targetTraceId, error, { steerTraceId, value });
|
||||
el.commandInput.value = value;
|
||||
syncCommandInputHeight();
|
||||
} finally {
|
||||
renderAgentChatStatus(deriveAgentChatStatus(), latestChatResult());
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
renderDrafts();
|
||||
renderDevicePodPanel(state.liveSurface);
|
||||
}
|
||||
}
|
||||
|
||||
function annotateSteerResult(targetTraceId, result, { steerTraceId, value }) {
|
||||
const index = state.chatMessages.findIndex((message) => message.role === "agent" && message.traceId === targetTraceId && message.status === "running");
|
||||
if (index < 0) return;
|
||||
state.chatMessages[index] = {
|
||||
...state.chatMessages[index],
|
||||
steerTraceId,
|
||||
lastSteerMessage: value,
|
||||
lastSteerResult: result,
|
||||
traceReplayStatus: result?.status ? `steer ${result.status}` : "steer accepted",
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function annotateSteerFailure(targetTraceId, error, { steerTraceId, value }) {
|
||||
const index = state.chatMessages.findIndex((message) => message.role === "agent" && message.traceId === targetTraceId && message.status === "running");
|
||||
if (index < 0) return;
|
||||
state.chatMessages[index] = {
|
||||
...state.chatMessages[index],
|
||||
steerTraceId,
|
||||
lastSteerMessage: value,
|
||||
traceReplayStatus: `steer 失败:${error?.userMessage ?? error?.message ?? "请求失败"}`,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function sessionIdForNextRequest() {
|
||||
return isTerminalSessionStatus(state.sessionStatus) ? undefined : state.sessionId;
|
||||
}
|
||||
@@ -828,6 +907,35 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI
|
||||
};
|
||||
}
|
||||
|
||||
async function sendAgentSteer(message, targetTraceId, steerTraceId, conversationId, sessionId, threadId) {
|
||||
const response = await fetchJson("/v1/agent/chat/steer", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Trace-Id": targetTraceId,
|
||||
"Prefer": "respond-async",
|
||||
"X-HWLAB-Short-Connection": "1"
|
||||
},
|
||||
timeoutMs: CODE_AGENT_SUBMIT_TIMEOUT_MS,
|
||||
timeoutName: "Code Agent steer",
|
||||
body: JSON.stringify({
|
||||
traceId: targetTraceId,
|
||||
targetTraceId,
|
||||
steerTraceId,
|
||||
message,
|
||||
conversationId,
|
||||
sessionId,
|
||||
threadId
|
||||
})
|
||||
});
|
||||
if (!response.ok) throw agentErrorFromHttpResponse(response, targetTraceId);
|
||||
return {
|
||||
...response.data,
|
||||
traceId: response.data?.traceId || targetTraceId,
|
||||
steerTraceId: response.data?.steerTraceId || steerTraceId
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForAgentMessageResult(traceId, accepted = {}) {
|
||||
const startedAt = Date.now();
|
||||
const resultPath = accepted?.resultUrl || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
|
||||
|
||||
@@ -303,7 +303,7 @@ export function traceDisplayRows(trace, events) {
|
||||
if (isCommandExecutionTraceEvent(event)) {
|
||||
const groupKey = traceCommandGroupKey(event);
|
||||
const group = commandGroups.get(groupKey) ?? { key: groupKey, started: event, completed: event, outputs: [] };
|
||||
const renderHere = event.label === "item/commandExecution:started" || group.started === event;
|
||||
const renderHere = isCommandExecutionStartedTraceEvent(event) || group.started === event;
|
||||
if (renderHere && !renderedCommands.has(groupKey)) {
|
||||
const row = traceCommandSummaryRow(trace, group);
|
||||
if (row) rows.push(row);
|
||||
@@ -383,7 +383,7 @@ function traceCommandExecutionGroups(events) {
|
||||
if (isCommandExecutionTraceEvent(event)) {
|
||||
const key = traceCommandGroupKey(event, activeKey);
|
||||
const group = ensureGroup(key, event);
|
||||
if (event.label === "item/commandExecution:started") {
|
||||
if (isCommandExecutionStartedTraceEvent(event)) {
|
||||
group.started = group.started ?? event;
|
||||
activeKey = key;
|
||||
} else {
|
||||
@@ -966,8 +966,15 @@ function traceCommandExecutionBody(event) {
|
||||
}
|
||||
|
||||
function isCommandExecutionTraceEvent(event) {
|
||||
return event?.type === "tool_call" && event?.toolName === "commandExecution" &&
|
||||
(event?.label === "item/commandExecution:started" || event?.label === "item/commandExecution:completed");
|
||||
if (event?.type !== "tool_call" || event?.toolName !== "commandExecution") return false;
|
||||
const label = String(event?.label ?? "");
|
||||
return label === "item/commandExecution:started" || label === "item/commandExecution:completed" ||
|
||||
label === "agentrun:tool:item/started" || label === "agentrun:tool:item/completed";
|
||||
}
|
||||
|
||||
function isCommandExecutionStartedTraceEvent(event) {
|
||||
const label = String(event?.label ?? "");
|
||||
return label === "item/commandExecution:started" || label === "agentrun:tool:item/started";
|
||||
}
|
||||
|
||||
function isGenericToolCallTraceEvent(event) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
toolCallsSummary as compactToolCallsSummary
|
||||
} from "./code-agent-facts.ts";
|
||||
import { classifyCodeAgentStatusSummary, codeAgentAvailabilityFromLive } from "./code-agent-status.ts";
|
||||
import { computeCodeAgentComposerState } from "./composer-policy.ts";
|
||||
import { classifyWorkbenchLiveStatus } from "./live-status.ts";
|
||||
import { marked } from "./third_party/marked/marked.esm.js";
|
||||
import { hardenRenderedMarkdown, renderMessageMarkdown } from "./message-markdown.ts";
|
||||
@@ -183,6 +184,7 @@ const state = {
|
||||
conversationId: null,
|
||||
sessionId: null,
|
||||
threadId: null,
|
||||
activeTraceId: null,
|
||||
workspaceId: null,
|
||||
workspaceRevision: null,
|
||||
workspaceUpdatedAt: null,
|
||||
@@ -429,6 +431,7 @@ function restoreCodeAgentSessionState() {
|
||||
state.workspaceId = nonEmptyString(payload.workspaceId);
|
||||
state.workspaceRevision = Number.isInteger(payload.workspaceRevision) ? payload.workspaceRevision : null;
|
||||
state.workspaceUpdatedAt = nonEmptyString(payload.workspaceUpdatedAt);
|
||||
state.activeTraceId = nonEmptyString(payload.activeTraceId);
|
||||
state.sessionStatus = nonEmptyString(payload.sessionStatus);
|
||||
state.chatMessages = Array.isArray(payload.chatMessages)
|
||||
? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean)
|
||||
@@ -495,6 +498,7 @@ function codeAgentSessionSnapshotPayload() {
|
||||
workspaceId: state.workspaceId,
|
||||
workspaceRevision: state.workspaceRevision,
|
||||
workspaceUpdatedAt: state.workspaceUpdatedAt,
|
||||
activeTraceId: state.currentRequest?.traceId ?? state.activeTraceId ?? activeTraceIdFromStoredMessages(state.chatMessages),
|
||||
conversationId: state.conversationId,
|
||||
sessionId: state.sessionId,
|
||||
threadId: state.threadId,
|
||||
@@ -549,6 +553,7 @@ function applyWorkbenchWorkspace(workspace) {
|
||||
state.workspaceId = nonEmptyString(workspace.workspaceId) ?? state.workspaceId;
|
||||
state.workspaceRevision = Number.isInteger(workspace.revision) ? workspace.revision : state.workspaceRevision;
|
||||
state.workspaceUpdatedAt = nonEmptyString(workspace.updatedAt) ?? state.workspaceUpdatedAt;
|
||||
state.activeTraceId = nonEmptyString(workspace.activeTraceId ?? workspace.workspace?.activeTraceId) ?? state.activeTraceId;
|
||||
state.conversationId = nonEmptyString(workspace.selectedConversationId) ?? nonEmptyString(workspace.workspace?.selectedConversationId) ?? state.conversationId;
|
||||
state.sessionId = nonEmptyString(workspace.selectedAgentSessionId) ?? nonEmptyString(workspace.workspace?.selectedAgentSessionId) ?? state.sessionId;
|
||||
state.sessionStatus = nonEmptyString(workspace.workspace?.sessionStatus) ?? state.sessionStatus;
|
||||
@@ -571,6 +576,7 @@ function workspaceToStoredSessionPayload(workspace) {
|
||||
workspaceId: nonEmptyString(workspace.workspaceId),
|
||||
workspaceRevision: Number.isInteger(workspace.revision) ? workspace.revision : null,
|
||||
workspaceUpdatedAt: nonEmptyString(workspace.updatedAt),
|
||||
activeTraceId: nonEmptyString(workspace.activeTraceId ?? workspaceJson.activeTraceId),
|
||||
conversationId,
|
||||
sessionId: nonEmptyString(workspace.selectedAgentSessionId ?? workspaceJson.selectedAgentSessionId),
|
||||
threadId: nonEmptyString(workspaceJson.threadId),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import { computeCodeAgentComposerState } from "./composer-policy.ts";
|
||||
|
||||
test("composer stays unlocked and routes active turns to steer", () => {
|
||||
const state = computeCodeAgentComposerState({
|
||||
chatPending: true,
|
||||
currentRequest: {
|
||||
traceId: "trc_active",
|
||||
conversationId: "cnv_active",
|
||||
sessionId: "ses_active",
|
||||
threadId: "thread-active"
|
||||
},
|
||||
messages: [{ role: "agent", status: "running", traceId: "trc_active" }]
|
||||
});
|
||||
|
||||
expect(state.locked).toBe(false);
|
||||
expect(state.disabled).toBe(false);
|
||||
expect(state.submitMode).toBe("steer");
|
||||
expect(state.route).toBe("/v1/agent/chat/steer");
|
||||
expect(state.targetTraceId).toBe("trc_active");
|
||||
expect(state.conversationId).toBe("cnv_active");
|
||||
});
|
||||
|
||||
test("composer routes idle or terminal state to a normal turn", () => {
|
||||
const state = computeCodeAgentComposerState({
|
||||
sessionStatus: "completed",
|
||||
workspace: {
|
||||
workspaceId: "wsp_1",
|
||||
revision: 3,
|
||||
selectedConversationId: "cnv_1",
|
||||
selectedAgentSessionId: "ses_1"
|
||||
}
|
||||
});
|
||||
|
||||
expect(state.locked).toBe(false);
|
||||
expect(state.disabled).toBe(false);
|
||||
expect(state.submitMode).toBe("turn");
|
||||
expect(state.route).toBe("/v1/agent/chat");
|
||||
expect(state.targetTraceId).toBeNull();
|
||||
expect(state.workspaceId).toBe("wsp_1");
|
||||
expect(state.workspaceRevision).toBe(3);
|
||||
});
|
||||
|
||||
test("composer treats workspace activeTraceId as a steerable Web turn", () => {
|
||||
const state = computeCodeAgentComposerState({
|
||||
workspace: {
|
||||
activeTraceId: "trc_shared_active",
|
||||
selectedConversationId: "cnv_shared",
|
||||
selectedAgentSessionId: "ses_shared",
|
||||
threadId: "thread-shared"
|
||||
}
|
||||
});
|
||||
|
||||
expect(state.locked).toBe(false);
|
||||
expect(state.disabled).toBe(false);
|
||||
expect(state.submitMode).toBe("steer");
|
||||
expect(state.targetTraceId).toBe("trc_shared_active");
|
||||
expect(state.route).toBe("/v1/agent/chat/steer");
|
||||
});
|
||||
|
||||
test("composer does not steer from stale terminal activeTraceId", () => {
|
||||
const state = computeCodeAgentComposerState({
|
||||
sessionStatus: "completed",
|
||||
workspace: {
|
||||
activeTraceId: "trc_stale_active",
|
||||
sessionStatus: "completed",
|
||||
selectedConversationId: "cnv_stale"
|
||||
}
|
||||
});
|
||||
|
||||
expect(state.locked).toBe(false);
|
||||
expect(state.disabled).toBe(false);
|
||||
expect(state.submitMode).toBe("turn");
|
||||
expect(state.targetTraceId).toBeNull();
|
||||
expect(state.route).toBe("/v1/agent/chat");
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
const ACTIVE_STATUSES = Object.freeze(["running", "pending", "accepted", "processing"]);
|
||||
const TERMINAL_STATUSES = Object.freeze(["completed", "source", "failed", "timeout", "canceled", "cancelled", "error", "blocked"]);
|
||||
|
||||
export function computeCodeAgentComposerState(input = {}) {
|
||||
const latest = latestAgentMessage(input.messages ?? input.chatMessages);
|
||||
const current = input.currentRequest && typeof input.currentRequest === "object" ? input.currentRequest : null;
|
||||
const workspace = input.workspace && typeof input.workspace === "object" ? input.workspace : null;
|
||||
const explicitStatus = normalizedStatus(input.status ?? input.sessionStatus ?? workspace?.sessionStatus);
|
||||
const traceId = firstNonEmptyString(
|
||||
input.targetTraceId,
|
||||
current?.traceId,
|
||||
runningTraceId(input.messages ?? input.chatMessages),
|
||||
workspace?.activeTraceId,
|
||||
latest?.status === "running" ? latest?.traceId : null
|
||||
);
|
||||
const active = Boolean(traceId) && (
|
||||
activeStatus(current?.status) ||
|
||||
activeStatus(latest?.status) ||
|
||||
activeStatus(explicitStatus) ||
|
||||
(firstNonEmptyString(input.activeTraceId, workspace?.activeTraceId) === traceId && !terminalStatus(explicitStatus)) ||
|
||||
input.chatPending === true
|
||||
);
|
||||
const mode = active ? "steer" : "turn";
|
||||
return pruneUndefined({
|
||||
contract: "hwlab-code-agent-composer-policy-v1",
|
||||
webEquivalent: true,
|
||||
locked: false,
|
||||
disabled: false,
|
||||
submitMode: mode,
|
||||
mode,
|
||||
route: mode === "steer" ? "/v1/agent/chat/steer" : "/v1/agent/chat",
|
||||
method: "POST",
|
||||
targetTraceId: active ? traceId : null,
|
||||
conversationId: firstNonEmptyString(input.conversationId, current?.conversationId, latest?.conversationId, workspace?.selectedConversationId),
|
||||
sessionId: firstNonEmptyString(input.sessionId, current?.sessionId, latest?.sessionId, workspace?.selectedAgentSessionId),
|
||||
threadId: firstNonEmptyString(input.threadId, current?.threadId, latest?.threadId, workspace?.threadId),
|
||||
workspaceId: firstNonEmptyString(input.workspaceId, workspace?.workspaceId),
|
||||
workspaceRevision: integerOrNull(input.workspaceRevision ?? workspace?.revision),
|
||||
reason: active ? "active-turn-steer" : terminalStatus(explicitStatus) ? "terminal-turn" : "idle-turn"
|
||||
});
|
||||
}
|
||||
|
||||
export function latestAgentMessage(messages = []) {
|
||||
if (!Array.isArray(messages)) return null;
|
||||
for (const message of [...messages].reverse()) {
|
||||
if (message?.role === "agent") return message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function runningTraceId(messages = []) {
|
||||
if (!Array.isArray(messages)) return null;
|
||||
for (const message of [...messages].reverse()) {
|
||||
if (message?.status !== "running") continue;
|
||||
const traceId = firstNonEmptyString(message?.traceId, message?.runnerTrace?.traceId);
|
||||
if (traceId) return traceId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function activeStatus(value) {
|
||||
return ACTIVE_STATUSES.includes(normalizedStatus(value));
|
||||
}
|
||||
|
||||
function terminalStatus(value) {
|
||||
return TERMINAL_STATUSES.includes(normalizedStatus(value));
|
||||
}
|
||||
|
||||
function normalizedStatus(value) {
|
||||
return String(value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function firstNonEmptyString(...values) {
|
||||
for (const value of values) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function integerOrNull(value) {
|
||||
return Number.isInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function pruneUndefined(value) {
|
||||
const out = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (item !== undefined) out[key] = item;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const requiredFiles = Object.freeze([
|
||||
"app-conversation.ts",
|
||||
"app-trace.ts",
|
||||
"app-helpers.ts",
|
||||
"composer-policy.ts",
|
||||
"auth.ts",
|
||||
"code-agent-facts.ts",
|
||||
"code-agent-status.ts",
|
||||
@@ -35,8 +36,11 @@ for (const file of requiredFiles) {
|
||||
if (!fs.existsSync(filePath)) throw new Error(`missing web asset: ${file}`);
|
||||
}
|
||||
|
||||
console.log("hwlab-cloud-web check: runtime assets present");
|
||||
await buildCloudWebDist(rootDir);
|
||||
console.log("hwlab-cloud-web check: dist bundle built");
|
||||
await assertCloudWebDistFresh(rootDir);
|
||||
console.log("hwlab-cloud-web check: dist freshness verified");
|
||||
|
||||
const html = readWeb("index.html");
|
||||
const styles = readWeb("styles.css");
|
||||
@@ -55,7 +59,10 @@ const protocol = readRepo("internal/protocol/index.mjs");
|
||||
const deployJson = readRepo("deploy/deploy.json");
|
||||
const artifactCatalog = readRepo("deploy/artifact-catalog.dev.json");
|
||||
|
||||
console.log("hwlab-cloud-web check: source files loaded");
|
||||
|
||||
const rightSidebar = sectionById(html, "aside", "device-pod-sidebar");
|
||||
console.log("hwlab-cloud-web check: right sidebar extracted");
|
||||
assert.ok(rightSidebar, "Device Pod right sidebar must exist");
|
||||
assert.match(html, /data-route="skills"/u, "Cloud Web must expose the skills activity route");
|
||||
assert.match(html, /id="skills"[^>]*data-view="skills"/u, "Cloud Web must expose a skills view");
|
||||
@@ -66,6 +73,7 @@ assert.match(app, /function\s+initSkillsPanel\s*\(/u, "missing initSkillsPanel")
|
||||
assert.match(app, /function\s+loadSkillsSurface\s*\(/u, "missing loadSkillsSurface");
|
||||
assert.match(app, /function\s+uploadSelectedSkillFiles\s*\(/u, "missing uploadSelectedSkillFiles");
|
||||
assert.match(app, /function\s+renderSkillPreview\s*\(/u, "missing renderSkillPreview");
|
||||
console.log("hwlab-cloud-web check: skills contracts verified");
|
||||
|
||||
for (const term of [
|
||||
"Device Pod",
|
||||
@@ -103,6 +111,7 @@ for (const selector of [
|
||||
assertIncludes(styles, selector, `missing Device Pod style ${selector}`);
|
||||
}
|
||||
assert.match(styles, /\.device-event-scroll\s*\{[\s\S]*?overscroll-behavior:\s*contain;/u, "event stream scroll must be contained");
|
||||
console.log("hwlab-cloud-web check: device pod layout contracts verified");
|
||||
|
||||
for (const fn of [
|
||||
"initDevicePodPanel",
|
||||
@@ -128,18 +137,23 @@ for (const route of [
|
||||
assertIncludes(app, route, `Device Pod frontend route missing ${route}`);
|
||||
}
|
||||
assert.match(app, /showModal\(\)/u, "Device Pod detail must use a modal dialog when available");
|
||||
assert.match(app, /followEvents[\s\S]*unreadEvents[\s\S]*lastEventLineCount/u, "event stream must track follow/unread state");
|
||||
assertIncludes(app, "followEvents", "event stream must track follow state");
|
||||
assertIncludes(app, "unreadEvents", "event stream must track unread state");
|
||||
assertIncludes(app, "lastEventLineCount", "event stream must track last line count");
|
||||
assert.match(app, /eventScrollUserActiveUntil/u, "event stream must track user scroll activity");
|
||||
assert.match(app, /function\s+isCodeAgentTimeoutError\s*\(/u, "restored Code Agent timeout reconciliation helper must be defined");
|
||||
assert.match(app, /function\s+renderDrafts\s*\(/u, "restored Code Agent state must not call an undefined draft renderer");
|
||||
assert.doesNotMatch(functionBody(app, "loadLiveSurface"), /\/v1\/m3|audit\.event\.query|evidence\.record\.query/u, "live surface must use Device Pod routes, not legacy hardware/evidence panels");
|
||||
console.log("hwlab-cloud-web check: device pod app contracts verified");
|
||||
|
||||
assert.match(liveStatus, /function classifyDevicePodProbe/u, "live status must classify Device Pod probe");
|
||||
assert.match(liveStatus, /function shortEvidenceToken\s*\(/u, "live status must define profile hash evidence shortener locally");
|
||||
assert.doesNotMatch(functionBody(app, "shouldReconcileRestoredCodeAgentResult"), /return\s+true\s*;/u, "restored Code Agent state must not blindly fetch stale result traces");
|
||||
assert.doesNotMatch(functionBody(app, "shouldReconcileRestoredCodeAgentResult"), /isRecoverableCodeAgentTerminalMessage/u, "restored Code Agent state must not fetch stale result traces just because a timeout is recoverable");
|
||||
assert.doesNotMatch(liveStatus, /classifyM3|serviceForM3Layer|\/v1\/m3/u, "live status must not keep legacy M3 probes");
|
||||
console.log("hwlab-cloud-web check: live status static contracts verified");
|
||||
await assertDevicePodEvidenceSummaryDoesNotCrash();
|
||||
console.log("hwlab-cloud-web check: live status dynamic contract verified");
|
||||
|
||||
assert.match(html, /rel="icon"[^>]*href="\/favicon\.svg"/u, "Cloud Web must declare a repo-owned favicon");
|
||||
assert.match(faviconSvg, /<svg\b[\s\S]*HW/u, "favicon.svg must be a real HWLAB SVG icon");
|
||||
@@ -169,10 +183,12 @@ for (const forbidden of [
|
||||
for (const helpTerm of ["Device Pod 看板", "summary", "弹窗", "纯文本事件流", "blocker", "跟随"]){
|
||||
assertIncludes(helpMarkdown, helpTerm, `help.md missing ${helpTerm}`);
|
||||
}
|
||||
console.log("hwlab-cloud-web check: frontend cleanup contracts verified");
|
||||
|
||||
for (const oldAsset of ["code-agent-m3-evidence.mjs", "wiring-status.mjs", "workbench-hardware-panel.mjs"]){
|
||||
assertDoesNotInclude(distContractScript, oldAsset, `legacy web runtime asset still copied: ${oldAsset}`);
|
||||
}
|
||||
console.log("hwlab-cloud-web check: legacy asset cleanup verified");
|
||||
|
||||
assertIncludes(cloudApiServer, "/v1/device-pods", "cloud-api must expose Device Pod REST routes");
|
||||
assertIncludes(cloudApiServer, "/v1/skills", "cloud-api must expose Skills REST routes");
|
||||
@@ -188,6 +204,7 @@ assertIncludes(devicePodService, "fake: false", "Device Pod service must not rep
|
||||
assertIncludes(protocol, "hwlab-device-pod", "protocol service ids must include Device Pod");
|
||||
assertIncludes(deployJson, "hwlab-device-pod", "deploy manifest must include Device Pod service");
|
||||
assertIncludes(artifactCatalog, "hwlab-device-pod", "artifact catalog must include Device Pod service");
|
||||
console.log("hwlab-cloud-web check: service contracts verified");
|
||||
|
||||
console.log("hwlab-cloud-web check ok: Device Pod summary sidebar, modal detail, simple event stream, TS build, and executor authority contracts are present");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user