From 66282ba80f00e9c23a85c93c6318f0fcde38bbde Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 12:24:56 +0800 Subject: [PATCH] fix: unlock cloud web composer steer --- docs/reference/spec-v02-hwlab-cli.md | 7 ++ docs/reference/spec-v02-hwlab-cloud-web.md | 4 + scripts/src/dev-cloud-workbench-smoke-lib.mjs | 8 ++ tools/hwlab-cli/client.test.ts | 108 +++++++++++++++++ tools/src/hwlab-cli-lib.ts | 109 +++++++++++++++++ web/hwlab-cloud-web/app-conversation.ts | 29 ++++- web/hwlab-cloud-web/app-device-pod.ts | 110 +++++++++++++++++- web/hwlab-cloud-web/app.ts | 6 + web/hwlab-cloud-web/composer-policy.test.ts | 77 ++++++++++++ web/hwlab-cloud-web/composer-policy.ts | 91 +++++++++++++++ web/hwlab-cloud-web/scripts/check.ts | 19 ++- 11 files changed, 563 insertions(+), 5 deletions(-) create mode 100644 web/hwlab-cloud-web/composer-policy.test.ts create mode 100644 web/hwlab-cloud-web/composer-policy.ts diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 17a51739..4e3ac8e9 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -24,6 +24,7 @@ - CLI 本地登录态必须支持 `--profile NAME` 隔离,同一 base URL 下不同 profile 写入 `.state/hwlab-cli/profiles//.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 `,让 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 ` 是运行中引导入口,必须调用 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 --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,也不能用自动交互脚本。 +## 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。 | diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index 357adea1..ce4a22a4 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -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 --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 --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。 | diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index a5bece46..7608d572 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -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) && diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index ac026600..f433f8bd 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -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({ diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 3a14d975..bbbdaaa9 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -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 diff --git a/web/hwlab-cloud-web/app-conversation.ts b/web/hwlab-cloud-web/app-conversation.ts index 345ade89..186f3ce1 100644 --- a/web/hwlab-cloud-web/app-conversation.ts +++ b/web/hwlab-cloud-web/app-conversation.ts @@ -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] = { diff --git a/web/hwlab-cloud-web/app-device-pod.ts b/web/hwlab-cloud-web/app-device-pod.ts index fcd3db5a..d04a06cb 100644 --- a/web/hwlab-cloud-web/app-device-pod.ts +++ b/web/hwlab-cloud-web/app-device-pod.ts @@ -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)}`; diff --git a/web/hwlab-cloud-web/app.ts b/web/hwlab-cloud-web/app.ts index 9b1631c5..a019f1db 100644 --- a/web/hwlab-cloud-web/app.ts +++ b/web/hwlab-cloud-web/app.ts @@ -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), diff --git a/web/hwlab-cloud-web/composer-policy.test.ts b/web/hwlab-cloud-web/composer-policy.test.ts new file mode 100644 index 00000000..e0f247d9 --- /dev/null +++ b/web/hwlab-cloud-web/composer-policy.test.ts @@ -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"); +}); diff --git a/web/hwlab-cloud-web/composer-policy.ts b/web/hwlab-cloud-web/composer-policy.ts new file mode 100644 index 00000000..f79c2da6 --- /dev/null +++ b/web/hwlab-cloud-web/composer-policy.ts @@ -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; +} diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 67cf78ca..76575ff7 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -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, /