From 2f435fc736312fbda7887a6ecf4fb06c8bea95e3 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 3 Jun 2026 11:06:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B9=E8=BF=9B=E6=89=8B=E5=8A=A8=20s?= =?UTF-8?q?ession=20CLI=20=E6=91=A9=E6=93=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/spec-v02-hwlab-cli.md | 4 ++- tools/hwlab-cli/client.test.ts | 47 ++++++++++++++++++++++++++++ tools/src/hwlab-cli-lib.ts | 25 ++++++++++++--- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index d52bf5dc..0f72d10c 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -15,7 +15,9 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workbench composer、`--from-trace` 或账号 workspace 自动创建、滚动或替换。账号 workspace 只能记录当前显式选中的 session、最近 trace 和展示状态;它不是隐式 session factory。 - 无 Code Agent session 时,必须先显式创建 session,再发送 turn。CLI 目标入口为 `client agent session create`;Web 目标入口为“新建 session”显式动作;Cloud API 目标入口为 `POST /v1/agent/sessions`。session 创建返回 `conversationId/sessionId`,`threadId` 可以在首轮 turn 被 provider/AgentRun 建立后回写。 +- `client agent session create|select|status` 的默认 JSON 输出必须在顶层直接暴露 `sessionId`、`conversationId`、`threadId`、`providerProfile`、`sessionStatus` 和 `sessionUsable` 这类短连接脚本高频字段;完整原始响应仍保留在 `session` 或 `body` 中。人工和 agent 不应为了拿 sessionId 手写一次性 JSON 深挖脚本,也不应依赖 `sed`/`grep` 解析 JSON。 - `client agent send` 必须携带显式 `--session-id`,或使用此前通过 `client agent session create|select` 明确选中的 workspace session;没有显式或已选 session 时返回结构化 `session_required`,不能自动生成 `conversationId/sessionId/threadId`。 +- 显式传入新的 `--conversation-id` 时,如果没有同时显式 `--session-id` 或 `--from-trace` 恢复出的 session,CLI 不得从账号 workspace 继承旧 session;这种情况必须返回 `session_required`,提示先为该 conversation 创建或选择 session。只有显式 conversation 与 workspace 当前 conversation 完全一致时,才允许使用 workspace 中已显式选中的 session。 - session 失败、`thread-resume-failed`、provider continuation 失效、用户取消或运行面中断时,当前 session 必须保留为 failed/stale/canceled 证据;系统不得自动滚动到新 session、不得隐式清理后继续,也不得把下一条普通消息路由到新 session。继续工作前必须显式创建或选择另一个 session。 - `--from-trace` 只用于 inspect 和显式复现 trace 所属 session;如果 trace 所属 session 已失败或 stale,CLI/Web/API 必须返回该失败 session 的证据和“请显式创建新 session”建议,不能自动替换 continuation。 - 最终 CLI 交互验收必须使用 HWLAB CLI 原入口,并按“登录 -> 显式创建或选择 session -> `client agent send --session-id ... --provider-profile minimax-m3 --message "在吗?"` -> result/trace”的顺序执行;不得用 UniDesk CLI 包装测试,也不得用 fresh auto session 掩盖失败 session 问题。 @@ -41,7 +43,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb - `client agent send --from-trace` 只能用 inspect 恢复 `conversationId/sessionId/threadId/retryOf` 和提交本轮原始消息;不得把 inspect 的 messages/facts 作为 `conversationContext`、`messages` 或 prompt 前缀提交。CLI 的 continuation 摘要只用于可见性,不是模型上下文。 - `client agent composer status|submit` 是 Cloud Web composer 的状态机等价入口。`status` 必须先恢复账号 workspace,再输出 `sessionRequired`、`sessionUsable`、`submitMode`、`route`、`targetTraceId`、conversation/session/thread 和 workspace revision;没有已选 session 时必须显示 `sessionRequired=true`。`submit` 只能在已显式选中可用 session 时提交 turn;运行中 trace 的 steer 仍走同源 `/v1/agent/chat/steer`,但不能借 steer/turn policy 自动创建或滚动 session。 - Code Agent continuation 的 thread 字段只有 `threadId` 一个标准名称。CLI 读取 inspect、`--from-trace` 回放、手动 `--thread-id` 提交和输出摘要都必须以该字段为唯一 thread identity;服务端响应也应保持同一字段口径。 -- `client agent send` 可以恢复账号 workspace 来读取“已显式选中”的 session,但 workspace 只代表 selection,不代表自动创建或自动恢复。`send` 只发送该 session 的 `conversationId/sessionId/threadId`、`workspaceId` 和 `expectedWorkspaceRevision`;终态轮询后 PATCH workspace 只能更新 session 状态、active trace 和 evidence。默认 workspace 恢复不恢复 messages/facts,不生成 `conversationContext`,也不得把历史文本拼入 prompt。显式传入新的 `--conversation-id` 不能隐式继承旧 session/thread;需要新 session 时必须先 `client agent session create`。 +- `client agent send` 可以恢复账号 workspace 来读取“已显式选中”的 session,但 workspace 只代表 selection,不代表自动创建或自动恢复。`send` 只发送该 session 的 `conversationId/sessionId/threadId`、`workspaceId` 和 `expectedWorkspaceRevision`;终态轮询后 PATCH workspace 只能更新 session 状态、active trace 和 evidence。默认 workspace 恢复不恢复 messages/facts,不生成 `conversationContext`,也不得把历史文本拼入 prompt。显式传入新的 `--conversation-id` 不能隐式继承旧 session/thread,CLI 层应在发出 `/v1/agent/chat` 前返回 `session_required`;需要新 session 时必须先 `client agent session create --conversation-id `。 - `client agent steer ` 是运行中引导入口,必须调用 Cloud Web 同源 `POST /v1/agent/chat/steer`,把 steer 文本装配成 AgentRun `type=steer` command 作用到目标 trace 的 active turn。CLI 不手动穿内部 URL;验收使用当前 runtime namespace/lane 自动解析的 `19666` Web 入口,并通过原 trace 的 result/trace 观察 steer 是否被 runner 接收和应用。 - `client agent trace --render web` 必须调用 Cloud Web trace row 的同一纯转换路径,输出 `render="web"`、renderer 标识、source event count、rendered row count、默认压制的 noise event count 和 row 摘要。浏览器 trace 展示错乱时,必须先用该 CLI 入口确认 Web 渲染转换是否已经乱序、重复、缺 final response、吞掉关键 row 或只显示泛化 tool call,再继续修浏览器 DOM/CSS。 - AgentRun v0.1 短连接 runner 已要求支持同 run/runner 多轮 command。CLI 仍应把 Web 提交的 `conversationId/sessionId/threadId` 原样送到 Cloud Web API,用于验证 adapter 是否在 runner reuse window 有效时复用同一个 AgentRun `runId` / `jobName` 并创建新 `commandId`;每轮都新建 runner 或重新 bundle 不是通过状态,trace 中的原因说明只能用于定位。 diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 22f66f54..4eba9332 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -474,6 +474,10 @@ test("hwlab-cli client agent session create stores selected workspace", async () assert.equal(calls[1].url, "http://web.test/v1/agent/sessions"); assert.equal(calls[1].body.providerProfile, "minimax-m3"); assert.equal(calls[1].body.workspaceId, "wsp_session_create"); + assert.equal(result.payload.sessionId, "ses_manual_create"); + assert.equal(result.payload.conversationId, "cnv_manual_create"); + assert.equal(result.payload.providerProfile, "minimax-m3"); + assert.equal(result.payload.sessionStatus, "idle"); assert.equal(result.payload.session.sessionId, "ses_manual_create"); assert.equal(result.payload.workspace.selectedAgentSessionId, "ses_manual_create"); @@ -482,6 +486,49 @@ test("hwlab-cli client agent session create stores selected workspace", async () assert.equal(session.workspace.selectedAgentSessionId, "ses_manual_create"); }); +test("hwlab-cli client agent send does not inherit workspace session for explicit new conversation", async () => { + const calls: any[] = []; + const result = await runHwlabCli([ + "client", + "agent", + "send", + "--base-url", + "http://web.test", + "--cookie", + "hwlab_session=session-a", + "--message", + "new conversation needs session", + "--trace-id", + "trc_new_conversation", + "--conversation-id", + "cnv_new_conversation" + ], { + 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_old_session", + revision: 8, + selectedConversationId: "cnv_old_conversation", + selectedAgentSessionId: "ses_old_session", + threadId: "thread-old", + workspace: { sessionStatus: "idle" } + } + }), { status: 200 }); + } + return new Response(JSON.stringify({ accepted: true }), { status: 202 }); + } + }); + + assert.equal(result.exitCode, 1); + assert.equal(result.payload.error.code, "session_required"); + assert.equal(result.payload.error.details.conversationId, "cnv_new_conversation"); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench"); +}); + test("hwlab-cli client agent send prefers selected conversation thread over stale workspace thread", async () => { const calls: any[] = []; const result = await runHwlabCli([ diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index f2f74891..1b41f581 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -803,8 +803,10 @@ async function agentSessionCreate(context: any) { }); const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/sessions", body, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS }); if (responseSucceeded(response)) await saveManualAgentSessionWorkspaceState(context, response.body, body); + const session = agentSessionSummary(response.body?.session, body); return responsePayload("client.agent.session.create", response, context, { route: route("POST", "/v1/agent/sessions"), + ...session, session: response.body?.session ?? null, workspace: workspaceSummaryFromSession(await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })), body: responseBodyForCli(response.body, context.parsed) @@ -823,9 +825,10 @@ async function agentSessionSelect(context: any) { }); const response = await requestJson({ ...context, method: "POST", path: pathName, body, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS }); if (responseSucceeded(response)) await saveManualAgentSessionWorkspaceState(context, response.body, body); + const session = agentSessionSummary(response.body?.session, { sessionId, providerProfile: body.providerProfile }); return responsePayload("client.agent.session.select", response, context, { route: route("POST", pathName), - sessionId, + ...session, session: response.body?.session ?? null, workspace: workspaceSummaryFromSession(await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })), body: responseBodyForCli(response.body, context.parsed) @@ -836,7 +839,7 @@ async function agentSessionStatus(context: any) { const sessionId = requiredText(context.rest[2] ?? context.parsed.sessionId, "sessionId"); const pathName = `/v1/agent/sessions/${encodeURIComponent(sessionId)}`; const response = await requestJson({ ...context, method: "GET", path: pathName }); - return responsePayload("client.agent.session.status", response, context, { route: route("GET", pathName), sessionId, body: responseBodyForCli(response.body, context.parsed) }); + return responsePayload("client.agent.session.status", response, context, { route: route("GET", pathName), ...agentSessionSummary(response.body?.session, { sessionId }), body: responseBodyForCli(response.body, context.parsed) }); } async function agentSessionList(context: any) { @@ -1268,9 +1271,12 @@ async function agentSend(context: any) { const workspaceState = parsed.noWorkspace === true ? null : await restoreWorkbenchWorkspace(context, { quiet: true }); const explicitConversationId = text(parsed.conversationId); const replayConversationId = replay.conversationId; + const requestedConversationId = explicitConversationId || replayConversationId; const workspaceConversationId = text(workspaceState?.selectedConversationId); - const sessionId = text(parsed.sessionId) || replay.sessionId || text(workspaceState?.selectedAgentSessionId); - const conversationId = explicitConversationId || replayConversationId || workspaceConversationId; + const explicitSessionId = text(parsed.sessionId); + const allowWorkspaceSession = !requestedConversationId || requestedConversationId === workspaceConversationId; + const sessionId = explicitSessionId || replay.sessionId || (allowWorkspaceSession ? text(workspaceState?.selectedAgentSessionId) : ""); + const conversationId = requestedConversationId || workspaceConversationId; const workspaceSessionId = text(workspaceState?.selectedAgentSessionId); const workspaceMatchesSelectedSession = workspaceSessionId === sessionId && (!conversationId || workspaceConversationId === conversationId); if (!sessionId) { @@ -2201,6 +2207,17 @@ function workspaceSummaryFromSession(session: any) { return normalizeWorkbenchWorkspace(session?.workspace); } +function agentSessionSummary(session: any, fallback: any = {}) { + return pruneUndefined({ + sessionId: text(session?.sessionId) || text(fallback.sessionId), + conversationId: text(session?.conversationId) || text(fallback.conversationId), + threadId: text(session?.threadId) || text(fallback.threadId), + providerProfile: text(session?.providerProfile) || text(fallback.providerProfile), + sessionStatus: text(session?.status) || text(fallback.status), + sessionUsable: typeof session?.usable === "boolean" ? session.usable : undefined + }); +} + function compactAgentMessagesForWorkspace(resultBody: any, fallback: any) { return [{ id: makeId("msg"),