fix: 改进手动 session CLI 摩擦

This commit is contained in:
Codex
2026-06-03 11:06:43 +08:00
parent ae1866c6ba
commit 2f435fc736
3 changed files with 71 additions and 5 deletions
+3 -1
View File
@@ -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` 恢复出的 sessionCLI 不得从账号 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 已失败或 staleCLI/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/threadCLI 层应在发出 `/v1/agent/chat` 前返回 `session_required`;需要新 session 时必须先 `client agent session create --conversation-id <ID>`
- `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 接收和应用。
- `client agent trace <traceId> --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 中的原因说明只能用于定位。
+47
View File
@@ -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([
+21 -4
View File
@@ -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"),