diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index e29006b5..e4c7143b 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -155,11 +155,10 @@ workspace;除非 Pod 重建、Codex supervisor 重启、用户显式清空对 可用时优先使用;server 内存 session 因 Pod/replica 切换丢失时,只要本地 session 未过期, 刷新页面不得要求用户重新登录。 -当后端使用 AgentRun v0.1 短连接 runner 时,Web 仍可把历史 `threadId` 作为会话连续性元数据 -提交,但 HWLAB adapter 不得把该历史 `threadId` 直接透传给新 runner 做 Codex app-server -`thread/resume`。AgentRun runner Job 是新执行面,旧 Web/Codex thread 可能没有对应 rollout; -adapter 应保留 `conversationId/sessionId` 和 redacted `requestedThreadId` 用于追踪,同时让新 -runner 建立自己的 app-server thread,避免 `no rollout found for thread id`。 +当后端使用 AgentRun v0.1 短连接 runner 时,Web 必须把当前 conversation 的 `threadId` 作为 +唯一会话连续性字段提交;HWLAB adapter 必须把它写入 AgentRun command `payload.threadId` +和 `SessionRef.threadId`,runner 只按该标准字段执行 Codex app-server `thread/resume`。resume 失败 +必须作为标准 `threadId` 路径上的结构化错误/trace 事件暴露。 恢复出的历史消息如果携带 `traceId`,前端必须先对 `/v1/agent/chat/result/` 做 终态归一,再回放 `/trace`。旧的 `running` 消息不能只因为 localStorage restore 被降级成 diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index a2f0e361..f9445f64 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`。 +- 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 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 lease 有效时复用同一个 AgentRun `runId` / `jobName` 并创建新 `commandId`;每轮都新建 runner 或重新 bundle 不是通过状态,trace 中的原因说明只能用于定位。 @@ -86,6 +87,10 @@ 阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:对一个来自 Cloud Web 的失败 trace 运行 `client agent send --from-trace --message "重试上一条" --provider-profile deepseek --no-wait`,确认 CLI 先访问 `/v1/agent/chat/inspect?traceId=`,再提交 `/v1/agent/chat`,输出包含 `replay.source`、`continuation.replayedFromTrace`、`conversationId/sessionId/threadId/retryOf`,且不输出 cookie、token 或 secret 原文。 +## T5.1 + +阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:复测取消后追问场景时,`client agent send/result/trace --render web` 输出只允许出现标准 `threadId`,不允许出现历史 thread 别名字段;第二轮必须是新 `commandId` 且 trace row 不包含上一 command 的尾部 assistant/tool/terminal 文本。 + ## T3A 阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:分别用 `--profile admin-a` 和 `--profile admin-a-second` 登录同一个账号,运行 `client workbench restore/status`,确认两个 profile 看到相同 `workspaceId` 和 revision;再用 `--profile other-user` 登录另一个账号,确认 workspace 不同;最后切回 `admin-a`,确认原 workspace、conversation/session/thread 仍能恢复。该验收必须使用真实 `http://74.48.78.17:19666`,不能 mock。 diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index a58b0244..b5783249 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -16,6 +16,9 @@ - `internal/cloud/access-control.ts` 也是账号 workspace authority:`account_workspaces` 记录同一账号的 Workbench 当前 workspace、selected conversation/session、active trace、provider profile 和 revision。 - `internal/db/runtime-store.ts` 和 `internal/cloud/db-contract.ts` 负责 Postgres runtime store 与 readiness 分层。 - `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。 +- AgentRun v0.1 接入只使用标准 `threadId` 路径:`POST /v1/agent/chat` 收到的 `conversationId/sessionId/threadId` 必须写入 AgentRun command `payload.threadId` 和 `SessionRef.threadId`;协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity。 +- AgentRun run 级 events 写回 HWLAB trace 时必须按当前 `commandId` 归属过滤;同一 run 的旧 command 尾部事件不能混入后续 command trace。取消、失败或 blocked 轮次如果已有 assistant/tool 可读上下文,必须以脱敏、限长的 partial context 写入 conversation facts,供后续 `inspect`/`--from-trace` 和同一 thread 的下一轮使用。 +- 传给 AgentRun runner Job 的 `transientEnv` 是短期运行上下文,数量不设固定 8 项上限;新增项必须仍满足最小必要、脱敏和 Secret 值不打印原则。 - 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。 - `hwlab-code-agent-workspace` PVC 挂载到 `/workspace/hwlab`,用于长会话 workspace;它是 cloud-api 运行资源,不是独立用户入口。 @@ -51,6 +54,10 @@ 阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:使用短连接 `POST /v1/agent/chat` 提交一次对话,再轮询 `/v1/agent/chat/result/{traceId}`,只有 `status=completed` 且 assistant reply 非空才算 Code Agent 通过。 +## T2.1 + +阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:在同一 `conversationId/sessionId/threadId` 中先发起一轮会产生可见 assistant/tool 进展的 Code Agent 请求并取消,再发送“回答一下刚才调查结果”这类后续消息;第二轮必须继续使用同一个标准 `threadId`,且 trace/result 不能混入上一 command 的尾部事件。 + ## T3 阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:未登录访问 `/v1/device-pods` 必须返回认证错误;登录后访问 device-pod list/status 时必须显示 `contractVersion=device-pod-authority-v1` 和 `fake=false` 来源,不得出现 fake fallback。 diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index c77298df..714d2d10 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -12,6 +12,8 @@ - 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`/下载排障。 - 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。 +- 同一 AgentRun run 复用多条 command 时,Web trace 展示只显示当前 command 归属事件和必要 run 级状态;旧 command 的 assistant/tool/terminal 尾部不能堆到新 command 末尾。取消轮次的可读进展必须作为脱敏 partial context 进入 conversation facts 和标准 thread,而不是靠旧 trace 尾部串线让后续轮次“碰巧看到”。 ## 内部架构 @@ -59,6 +61,10 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验, 阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:在同一 conversation/session 连续发送两条 Code Agent 消息,确认第二条复用第一条的 AgentRun `runId` 和 runner `jobName`、生成新的 `commandId`,且不重新 materialize bundle/启动新 runner;result completed 必须包含真实 provider/model/`providerTrace`/trace/conversation 和 final assistant response。复用失败原因只能作为诊断,不作为本测试通过条件。 +## T2.3 + +阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:对一轮已取消但取消前存在 assistant/tool 进展的 trace 发送同一 conversation/session/thread 后续问题,确认请求和 result 只携带标准 `threadId`,不出现历史 thread 别名字段;`--render web` 输出不得把上一 command 的尾部 assistant/tool/terminal row 堆到新 command 末尾。 + ## T3 阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:打开 Workbench device-pod 面板,确认 status/freshness/blocker 显示来自 `/v1/device-pods`,未登录或未授权时必须显示认证/授权 blocker,不得把 fixture 或 blocked fallback 写成真实硬件 DEV-LIVE。 diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 289e3421..a69e72ef 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -64,7 +64,6 @@ export function initialAgentRunChatResult({ params = {}, options = {}, traceId } capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, sessionMode: CODEX_STDIO_SESSION_MODE, implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - requestedThreadId: safeOpaqueId(params.threadId) || null, threadContinuityPolicy: THREAD_CONTINUITY_POLICY, agentRun: { adapter: ADAPTER_ID, @@ -376,6 +375,7 @@ export function agentRunSessionEvidence(payload = {}) { function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId }) { const commitId = fullSourceCommit(env); + const threadId = safeOpaqueId(params.threadId); const resourceBundleRef = commitId ? { kind: "git", @@ -400,12 +400,12 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses sessionRef: { sessionId: sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile), ...(safeConversationId(params.conversationId) ? { conversationId: safeConversationId(params.conversationId) } : {}), + ...(threadId ? { threadId } : {}), metadata: { adapter: ADAPTER_ID, hwlabTraceId: traceId, hwlabApi: "/v1/agent/chat", hwlabSessionId: safeSessionId(params.sessionId) || null, - requestedThreadId: safeOpaqueId(params.threadId) || null, threadContinuityPolicy: THREAD_CONTINUITY_POLICY, sessionPolicy: SESSION_POLICY_RUN_LOCAL, agentRunSessionProfile: backendProfile, @@ -438,6 +438,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }) { const prompt = String(params.message ?? params.prompt ?? "").trim(); + const threadId = safeOpaqueId(params.threadId); return { type: "turn", payload: { @@ -447,8 +448,7 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId conversationId: safeConversationId(params.conversationId) || null, sessionId: sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile), hwlabSessionId: safeSessionId(params.sessionId) || null, - threadId: null, - requestedThreadId: safeOpaqueId(params.threadId) || null, + threadId: threadId || null, threadContinuityPolicy: THREAD_CONTINUITY_POLICY, sessionPolicy: SESSION_POLICY_RUN_LOCAL, providerProfile: backendProfile, @@ -734,6 +734,7 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) }); const code = canceled ? "agentrun_canceled" : result?.failureKind ?? (terminalStatus === "blocked" ? "agentrun_blocked" : "agentrun_failed"); const message = result?.failureMessage ?? result?.blocker?.message ?? (canceled ? "AgentRun command was canceled" : "AgentRun command failed"); + const partialContext = partialAgentRunContext(runnerTrace); return { ...base, status: canceled ? "canceled" : "failed", @@ -742,7 +743,7 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) session: agentRunSessionSummary(base, canceled ? "canceled" : "failed"), sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true), runner: agentRunRunnerSummary(base.agentRun), - runnerTrace, + runnerTrace: partialContext ? { ...runnerTrace, partialContext } : runnerTrace, toolCalls: agentRunToolCalls(result, canceled ? "canceled" : "failed"), skills: { status: "delegated", provider: ADAPTER_ID, count: 0, items: [], valuesPrinted: false }, providerTrace, @@ -768,6 +769,28 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) toolName: "agentrun.manual-dispatch" }, agentRun: { ...base.agentRun, terminalStatus, completed: false, providerTrace, valuesPrinted: false }, + ...(partialContext ? { partialContext } : {}), + valuesPrinted: false + }; +} + +function partialAgentRunContext(runnerTrace = {}) { + const events = Array.isArray(runnerTrace.events) ? runnerTrace.events : []; + const assistantMessages = events + .filter((event) => event?.type === "assistant" && String(event.text ?? event.message ?? "").trim()) + .map((event) => String(event.text ?? event.message).trim()) + .slice(-4); + const toolEvidence = events + .filter((event) => event?.type === "tool_call" && String(event.status ?? "") === "completed") + .map((event) => [event.toolName ?? event.label ?? "tool", event.command ?? event.outputSummary ?? event.stdoutSummary ?? event.message ?? ""].filter(Boolean).join(": ")) + .filter(Boolean) + .slice(-6); + if (assistantMessages.length === 0 && toolEvidence.length === 0) return null; + return { + status: "partial-before-terminal", + summary: "AgentRun terminal 前已有可延续的 assistant/tool 上下文;后续同 conversation/session/thread 轮次必须能继续使用。", + assistantMessages, + toolEvidence, valuesPrinted: false }; } @@ -795,11 +818,20 @@ function decorateAgentRunRunningResult({ base, mapping, traceStore, traceId }) { function appendAgentRunEventsToTrace(traceStore, traceId, events, mapping = {}) { for (const event of events) { + if (isForeignAgentRunCommandEvent(event, mapping)) continue; const normalized = mapAgentRunEvent(event, mapping); if (normalized) traceStore.append(traceId, normalized, agentRunTraceMeta({}, {})); } } +function isForeignAgentRunCommandEvent(event, mapping = {}) { + const currentCommandId = typeof mapping.commandId === "string" ? mapping.commandId : ""; + if (!currentCommandId) return false; + const payload = event?.payload && typeof event.payload === "object" ? event.payload : {}; + const eventCommandId = typeof payload.commandId === "string" ? payload.commandId : ""; + return Boolean(eventCommandId && eventCommandId !== currentCommandId); +} + function mapAgentRunEvent(event, mapping = {}) { if (!event || typeof event !== "object") return null; const payload = event.payload && typeof event.payload === "object" ? event.payload : {}; diff --git a/internal/cloud/code-agent-chat.ts b/internal/cloud/code-agent-chat.ts index 0a63f71d..ae185f3b 100644 --- a/internal/cloud/code-agent-chat.ts +++ b/internal/cloud/code-agent-chat.ts @@ -245,7 +245,10 @@ export async function handleCodeAgentChat(params = {}, options = {}) { if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations; if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility; if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate; - if (error.conversationFacts !== undefined) { + const factPayload = shouldRecordErrorConversationFact(payload) ? attachConversationFacts(decorateChatSessionLifecycle(payload), sessionRegistry, { now: options.now }) : null; + if (factPayload?.conversationFacts) { + payload.conversationFacts = factPayload.conversationFacts; + } else if (error.conversationFacts !== undefined) { payload.conversationFacts = error.conversationFacts; } else if (typeof sessionRegistry.getConversationFacts === "function") { payload.conversationFacts = sessionRegistry.getConversationFacts(conversationId); @@ -263,6 +266,14 @@ export async function handleCodeAgentChat(params = {}, options = {}) { } } +function shouldRecordErrorConversationFact(payload = {}) { + if (!payload || typeof payload !== "object") return false; + if (payload.status === "running" || payload.status === "completed") return false; + if (payload.partialContext) return true; + if (payload.runnerTrace?.partialContext) return true; + return Boolean(payload.runnerTrace?.traceId && payload.sessionId && payload.conversationId); +} + function completedRunnerPayload({ base, runnerResult, messageId, now, sessionRegistry }) { const completedAt = nowIso(now); const blocker = structuredCompletionBlocker(runnerResult, { diff --git a/internal/cloud/code-agent-session-registry.ts b/internal/cloud/code-agent-session-registry.ts index 347f202d..b27b32d0 100644 --- a/internal/cloud/code-agent-session-registry.ts +++ b/internal/cloud/code-agent-session-registry.ts @@ -477,11 +477,31 @@ function sanitizeConversationFact(fact, { conversationId, timestamp, maxFactTool toolCalls, skills, runnerTrace: sanitizeRunnerTrace(runnerTrace), + partialContext: sanitizePartialContext(fact.partialContext ?? fact.partialConversationContext ?? runnerTrace?.partialContext), valuesRedacted: true, secretMaterialStored: false }; } +function sanitizePartialContext(value) { + if (!value || typeof value !== "object") return null; + const assistantMessages = Array.isArray(value.assistantMessages) + ? value.assistantMessages.map((item) => safeFactText(item, 240)).filter(Boolean).slice(-4) + : []; + const toolEvidence = Array.isArray(value.toolEvidence) + ? value.toolEvidence.map((item) => safeFactText(item, 200)).filter(Boolean).slice(-6) + : []; + const summary = safeFactText(value.summary, 400); + if (!summary && assistantMessages.length === 0 && toolEvidence.length === 0) return null; + return { + status: safeFactText(value.status ?? "partial", 40) ?? "partial", + summary, + assistantMessages, + toolEvidence, + valuesRedacted: true + }; +} + function sanitizeToolCalls(toolCalls, limit) { const items = Array.isArray(toolCalls) ? toolCalls.slice(0, limit).map((toolCall) => ({ @@ -544,6 +564,7 @@ function sanitizeRunnerTrace(runnerTrace) { : [], route: safeFactText(runnerTrace.route, 120), status: safeFactText(runnerTrace.status, 80), + partialContext: sanitizePartialContext(runnerTrace.partialContext), outputTruncated: runnerTrace.outputTruncated === true, valuesPrinted: false }; diff --git a/internal/cloud/codex-stdio-session.ts b/internal/cloud/codex-stdio-session.ts index 2e544b49..09c94a21 100644 --- a/internal/cloud/codex-stdio-session.ts +++ b/internal/cloud/codex-stdio-session.ts @@ -1143,7 +1143,7 @@ export function createCodexStdioSessionManager(options = {}) { const timestampMs = Date.parse(timestamp); const conversationId = requiredId(params.conversationId, "cnv"); const requestedSessionId = optionalId(params.requestedSessionId); - const requestedThreadId = optionalId(params.threadId); + const threadId = optionalId(params.threadId); const mappedSessionId = conversations.get(conversationId) ?? null; if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) { @@ -1162,7 +1162,7 @@ export function createCodexStdioSessionManager(options = {}) { let reused = Boolean(session); if (session && sessionExpired(session, timestampMs)) { - const resumeThreadId = requestedThreadId ?? optionalId(session.threadId); + const resumeThreadId = threadId ?? optionalId(session.threadId); if (["idle", "expired"].includes(session.status) && resumeThreadId) { session.status = "idle"; session.threadId = resumeThreadId; @@ -1241,7 +1241,7 @@ export function createCodexStdioSessionManager(options = {}) { lastTraceId: optionalId(params.traceId), currentTraceId: null, turn: 0, - threadId: requestedThreadId, + threadId, durable: true, longLivedSession: true, codexStdio: true, @@ -1249,11 +1249,11 @@ export function createCodexStdioSessionManager(options = {}) { secretMaterialStored: false }; sessions.set(effectiveSessionId, session); - reused = Boolean(requestedThreadId); + reused = Boolean(threadId); } session.conversationIds.add(conversationId); - session.threadId = requestedThreadId ?? session.threadId; + session.threadId = threadId ?? session.threadId; session.workspace = params.workspace ?? session.workspace; session.sandbox = params.sandbox ?? session.sandbox; session.status = "busy"; diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 5692ce10..4af5fe71 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -174,12 +174,11 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.deepEqual(body.resourceBundleRef.toolAliases, [ { name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" } ]); - assert.equal(Object.hasOwn(body.sessionRef, "threadId"), false); + assert.equal(body.sessionRef.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); assert.equal(body.sessionRef.sessionId, "ses_agentrun_deepseek_server_test_agentrun"); assert.equal(body.sessionRef.metadata.hwlabSessionId, "ses_server-test-agentrun"); assert.equal(body.sessionRef.metadata.agentRunSessionProfile, "deepseek"); assert.equal(body.sessionRef.metadata.agentRunSessionPolicy, "backend-profile-scoped"); - assert.equal(body.sessionRef.metadata.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); assert.equal(body.sessionRef.metadata.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread"); assert.equal(body.sessionRef.metadata.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse"); return send({ id: "run_hwlab_adapter", status: "pending", backendProfile: "deepseek", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef }); @@ -189,8 +188,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.match(body.payload.prompt, /AgentRun adapter/u); assert.equal(body.payload.sessionId, "ses_agentrun_deepseek_server_test_agentrun"); assert.equal(body.payload.hwlabSessionId, "ses_server-test-agentrun"); - assert.equal(body.payload.threadId, null); - assert.equal(body.payload.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); + assert.equal(body.payload.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); assert.equal(body.payload.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread"); assert.equal(body.payload.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse"); const secondTurn = /第二轮/u.test(body.payload.prompt); @@ -217,7 +215,6 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(transientEnv.HWLAB_DEVICE_POD_SESSION_TOKEN, "test-device-pod-session-token"); assert.equal(Object.hasOwn(transientEnv, "HWLAB_DEVICE_POD_API_URL"), false); assert.equal(Object.hasOwn(transientEnv, "HWLAB_CODE_AGENT_DEVICE_POD_API_URL"), false); - assert.ok(body.transientEnv.length <= 8); return send({ action: "create-kubernetes-job", runId: "run_hwlab_adapter", @@ -231,15 +228,20 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte }); } if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/events") { - const second = url.searchParams.get("afterSeq") === "3" || calls.some((call) => call.path === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter_second/result"); + const afterSeq = Number.parseInt(url.searchParams.get("afterSeq") ?? "0", 10); + const second = afterSeq >= 5 || url.searchParams.get("afterSeq") === "3" || calls.some((call) => call.path === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter_second/result"); + if (second) return send({ items: [ + { id: "evt_old_tail", runId: "run_hwlab_adapter", seq: 6, type: "assistant_message", payload: { commandId: "cmd_hwlab_adapter", text: "旧 command 尾部不应进入第二轮。" }, createdAt: "2026-06-01T00:00:02.500Z" }, + { id: "evt_7", runId: "run_hwlab_adapter", seq: 7, type: "backend_status", payload: { phase: "turn-started", commandId: "cmd_hwlab_adapter_second", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:03.000Z" }, + { id: "evt_8", runId: "run_hwlab_adapter", seq: 8, type: "assistant_message", payload: { commandId: "cmd_hwlab_adapter_second", text: "AgentRun adapter 复用已有 runner 完成第二轮。" }, createdAt: "2026-06-01T00:00:04.000Z" }, + { id: "evt_9", runId: "run_hwlab_adapter", seq: 9, type: "terminal_status", payload: { commandId: "cmd_hwlab_adapter_second", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:05.000Z" } + ] }); return send({ items: [ - { id: second ? "evt_4" : "evt_1", runId: "run_hwlab_adapter", seq: second ? 4 : 1, type: "backend_status", payload: { phase: second ? "turn-started" : "runner-job-created", commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:00.000Z" }, - ...(second ? [] : [ + { 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_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: second ? "evt_5" : "evt_2", runId: "run_hwlab_adapter", seq: second ? 5 : 4, type: "assistant_message", payload: { commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", text: second ? "AgentRun adapter 复用已有 runner 完成第二轮。" : "AgentRun adapter 已接管 HWLAB Code Agent。" }, createdAt: "2026-06-01T00:00:01.000Z" }, - { id: second ? "evt_6" : "evt_3", runId: "run_hwlab_adapter", seq: second ? 6 : 5, type: "terminal_status", payload: { commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:02.000Z" } + { 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" } ] }); } if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter/result") { @@ -275,8 +277,8 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte terminalStatus: "completed", completed: true, reply: "AgentRun adapter 复用已有 runner 完成第二轮。", - lastSeq: 6, - eventCount: 6, + lastSeq: 9, + eventCount: 9, sessionRef: { sessionId: "ses_agentrun_deepseek_server_test_agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" } }); } @@ -408,6 +410,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.match(secondPayload.reply.content, /复用已有 runner/u); assert.ok(secondPayload.runnerTrace.events.some((event) => event.label === "agentrun:run:reused")); assert.ok(secondPayload.runnerTrace.events.some((event) => event.label === "agentrun:runner-job:reused")); + assert.equal(secondPayload.runnerTrace.events.some((event) => String(event.text ?? event.message ?? "").includes("旧 command 尾部")), false); assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs").length, 1); assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs/run_hwlab_adapter/runner-jobs").length, 1); assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs/run_hwlab_adapter/commands").length, 2); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 6a21541d..f51c235a 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -344,6 +344,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) { runnerTrace: traceStore.snapshot(traceId), updatedAt: new Date().toISOString() }, params); + recordCodeAgentConversationFact(payload, executionOptions); results.set(traceId, payload); traceStore.append(traceId, { type: "result", @@ -478,6 +479,7 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti return; } if (synced.result && synced.result.status !== "running") { + recordCodeAgentConversationFact(synced.result, options); await recordCodeAgentSessionOwner({ payload: synced.result, params: synced.result, options, status: synced.result.status === "completed" ? "active" : synced.result.status }); sendJson(response, 200, compactCodeAgentChatResultPayload(synced.result, options)); return; @@ -837,10 +839,41 @@ export async function handleCodeAgentCancelHttp(request, response, options) { updatedAt: new Date().toISOString() }; await recordCodeAgentSessionOwner({ payload, params: { ...params, traceId, ownerUserId: options.actor?.id, ownerRole: options.actor?.role }, options, status: "canceled" }); + recordCodeAgentConversationFact(payload, options); options.codeAgentChatResults?.set(traceId, annotateOwner(payload, { ownerUserId: options.actor?.id, ownerRole: options.actor?.role })); sendJson(response, 200, payload); } +function recordCodeAgentConversationFact(payload = {}, options = {}) { + const registry = options.sessionRegistry; + const conversationId = safeConversationId(payload.conversationId); + if (!conversationId || typeof registry?.recordFact !== "function") return null; + try { + return registry.recordFact(conversationId, { + kind: payload.status === "completed" ? "runner_turn" : "runner_turn_terminal", + conversationId, + sessionId: payload.sessionId, + traceId: payload.traceId, + provider: payload.provider, + backend: payload.backend, + workspace: payload.workspace, + sandbox: payload.sandbox, + session: payload.session, + sessionMode: payload.sessionMode, + sessionReuse: payload.sessionReuse, + implementationType: payload.implementationType, + runner: payload.runner, + runnerTrace: payload.runnerTrace, + partialContext: payload.partialContext ?? payload.runnerTrace?.partialContext, + capabilityLevel: payload.capabilityLevel, + toolCalls: payload.toolCalls, + skills: payload.skills + }, { now: options.now }); + } catch { + return null; + } +} + async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options = {}, status = "active" } = {}) { const ownerUserId = options.actor?.id ?? params.ownerUserId; if (!ownerUserId || !options.accessController?.recordAgentSessionOwner) return null; diff --git a/web/hwlab-cloud-web/app-device-pod.ts b/web/hwlab-cloud-web/app-device-pod.ts index c691d705..fcd3db5a 100644 --- a/web/hwlab-cloud-web/app-device-pod.ts +++ b/web/hwlab-cloud-web/app-device-pod.ts @@ -102,11 +102,11 @@ async function submitAgentMessage(value, options = {}) { const traceId = options.traceId ?? nextProtocolId("trc"); const activeConversationId = options.conversationId ?? state.conversationId ?? nextProtocolId("cnv"); const requestedSessionId = options.sessionId === undefined ? sessionIdForNextRequest() : options.sessionId; - const requestedThreadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId; + const threadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId; const pendingContinuity = pendingSessionContinuity({ conversationId: activeConversationId, sessionId: requestedSessionId, - threadId: requestedThreadId, + threadId, retryOf: options.retryOf }); state.conversationId = activeConversationId; @@ -119,7 +119,7 @@ async function submitAgentMessage(value, options = {}) { traceId, conversationId: activeConversationId, sessionId: requestedSessionId, - threadId: requestedThreadId, + threadId, retryOf: options.retryOf ?? null, createdAt: new Date().toISOString() }; @@ -132,7 +132,7 @@ async function submitAgentMessage(value, options = {}) { traceId, conversationId: activeConversationId, sessionId: requestedSessionId, - threadId: requestedThreadId, + threadId, sessionContinuity: pendingContinuity, retryInput: value, retryOf: options.retryOf ?? null, @@ -145,7 +145,7 @@ async function submitAgentMessage(value, options = {}) { traceId, conversationId: activeConversationId, sessionId: requestedSessionId, - threadId: requestedThreadId, + threadId, messageId: pendingMessage.id, input: value, lastActivityAt: Date.now(), @@ -165,14 +165,13 @@ async function submitAgentMessage(value, options = {}) { const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id); try { - const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, requestedThreadId); + const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, threadId); stopTraceStream(); if (state.canceledTraces.has(traceId)) return; const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); const updatedMessage = applyCodeAgentResultToMessage(pendingMessage.id, result, { baseMessage: pendingMessage, pendingContinuity, - requestedThreadId, retryInput: value, retryOf: options.retryOf ?? null }); @@ -200,7 +199,7 @@ async function submitAgentMessage(value, options = {}) { text: presentation.text, status: failedStatus, traceId: error.traceId || traceId, - threadId: requestedThreadId, + threadId, sessionContinuity: failedSessionContinuity(pendingContinuity, error), updatedAt: new Date().toISOString(), retryInput: value, @@ -292,7 +291,7 @@ function applyCodeAgentResultToMessage(messageId, result, options = {}) { traceId: result.traceId || current.traceId, conversationId: result.conversationId || result.sessionId || state.conversationId, sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId, - threadId: resultThreadId || options.requestedThreadId || current.threadId || state.threadId, + threadId: resultThreadId || current.threadId || state.threadId, sessionContinuity, messageId: result.messageId, provider: result.provider, @@ -782,9 +781,9 @@ function renderProbePending() { renderCodeAgentSummary(); } -async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest(), requestedThreadId = threadIdForNextRequest()) { +async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest(), threadIdForRequest = threadIdForNextRequest()) { const sessionId = requestedSessionId || undefined; - const threadId = requestedThreadId || undefined; + const threadId = threadIdForRequest || undefined; const response = await fetchJson("/v1/agent/chat", { method: "POST", headers: { diff --git a/web/hwlab-cloud-web/auth.test.ts b/web/hwlab-cloud-web/auth.test.ts index 087a8d21..79cbebf8 100644 --- a/web/hwlab-cloud-web/auth.test.ts +++ b/web/hwlab-cloud-web/auth.test.ts @@ -20,7 +20,7 @@ test("server auth is authoritative when restoring the workbench", async () => { assert.equal(session.authenticated, true); assert.equal(session.mode, "server"); - assert.deepEqual(session.user, { username: "server-admin" }); + assert.equal(session.user.username, "server-admin"); assert.deepEqual(harness.fetchCalls.map((call) => call.path), ["/auth/session"]); assert.deepEqual(harness.fetchCalls.map((call) => call.method), ["GET"]); assert.equal(harness.loginShell.hidden, true);