From 29e9e23fb337c5bc8d88779404f23af32828e4d9 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 16:22:27 +0800 Subject: [PATCH] fix: use native codex stdio continuation --- docs/reference/spec-v02-hwlab-cli.md | 1 + docs/reference/spec-v02-hwlab-cloud-api.md | 6 +- docs/reference/spec-v02-hwlab-cloud-web.md | 3 +- internal/cloud/code-agent-agentrun-adapter.ts | 113 +----------------- internal/cloud/server-agent-chat.test.ts | 27 +++-- internal/cloud/server-code-agent-http.ts | 92 +++----------- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 8 +- web/hwlab-cloud-web/code-agent-facts.ts | 2 +- 8 files changed, 47 insertions(+), 205 deletions(-) diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index ef077e81..8481fbbc 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -26,6 +26,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 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,再用与 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` 才跳过这一默认恢复路径。 diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index cd8266af..f31bc597 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -17,8 +17,8 @@ - `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 completed 轮次续接不能只依赖 runner 内部 `threadId` 记忆;Cloud API 必须从请求、account workspace 或 account conversation 中取同一 `conversationId/sessionId/threadId` 的历史消息,生成脱敏、限长的 `conversationContext`,并把它并入 AgentRun command `payload.prompt`,同时保留 `payload.message` 为本轮原始用户消息。该上下文不得包含 device-pod session token、provider key 或其他 Secret 值;trace 只记录上下文来源和条数。 +- AgentRun run 级 events 写回 HWLAB trace 时必须按当前 `commandId` 归属过滤;同一 run 的旧 command 尾部事件不能混入后续 command trace。取消、失败或 blocked 轮次如果已有 assistant/tool 可读进展,必须以脱敏、限长的 conversation facts 写入 UI/trace/inspect 证据,供后续 `inspect`/`--from-trace` 可见性使用;这些 facts 不得作为下一轮模型上下文或 prompt 拼接来源。 +- AgentRun completed 轮次续接必须依赖 Codex stdio 原生 session continuation。Cloud API 只把本轮原始 `message/prompt` 和标准 `conversationId/sessionId/threadId` 写入 AgentRun command payload 与 `SessionRef`;不得从请求、account workspace 或 account conversation 生成 `conversationContext`,不得把历史消息拼入 prompt,也不得把请求体里的 `conversationContext/messages` 当作模型上下文。历史 conversation facts 只用于 UI、inspect、trace 和 `--from-trace` 的可见性证据;收到 synthetic context 字段时只能记录 ignored trace 并剥离。`thread/resume` 失败时按 AgentRun `thread-resume-failed` 终止本轮。 - Cloud API 通过 AgentRun v0.1 `runner-jobs.transientEnv` 传递本次 Code Agent turn 的短期上下文,例如 `HWLAB_RUNTIME_*`、`HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME` 和 device-pod session token。`transientEnv` 不设固定 8 项上限,新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value,并继续禁止承载 GitHub token、provider key、长期 SSH key 或其他可复用 credential;文档、日志和 trace 只允许保留脱敏后的 name、来源或摘要,不打印 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 运行资源,不是独立用户入口。 @@ -61,7 +61,7 @@ ## T2.2 -阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:在同一 `conversationId/sessionId/threadId` 中先完成一轮“看看 device-pod 可用性?”这类 Code Agent 请求,再发送“总结我们刚才的对话内容”;第二轮 AgentRun command 必须包含脱敏历史上下文,并且 assistant reply 不能回答“这是本会话第一条消息”或等价的新会话结论。 +阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:在同一 `conversationId/sessionId/threadId` 中先完成一轮“看看 device-pod 可用性?”这类 Code Agent 请求,再发送“总结我们刚才的对话内容”;第二轮 AgentRun command 必须只包含本轮原始 message/prompt、同一个标准 threadId 和必要运行元数据,不得包含 `conversationContext/messages` 或历史 prompt 拼接。assistant reply 必须通过 Codex stdio 原生 `thread/resume` 记住第一轮,不能回答“这是本会话第一条消息”或等价的新会话结论;若 `thread/resume` 失败,应以 `thread-resume-failed` 终止。 ## T3 diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index ce4a22a4..c8c3d260 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -14,7 +14,8 @@ - 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 尾部串线让后续轮次“碰巧看到”。 +- Cloud Web 提交 Code Agent turn 时只发送当前用户消息、共享 workspace 的 `conversationId/sessionId/threadId`、workspace revision 和必要运行元数据;不得发送 `conversationContext/messages`,也不得把浏览器历史拼入 prompt。历史消息只用于本地 UI 展示和 trace/inspect 可见性,不能替代 AgentRun/Codex stdio 原生 `thread/resume`。 +- 同一 AgentRun run 复用多条 command 时,Web trace 展示只显示当前 command 归属事件和必要 run 级状态;旧 command 的 assistant/tool/terminal 尾部不能堆到新 command 末尾。取消轮次的可读进展必须作为脱敏 conversation facts 进入 UI/trace/inspect 证据,而不是靠旧 trace 尾部串线让后续轮次“碰巧看到”;这些 facts 不得作为下一轮模型上下文或 prompt 拼接来源。 ## 内部架构 diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 6312523a..713f2e26 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -33,9 +33,6 @@ const AGENTRUN_BACKENDS = Object.freeze(["codex", "deepseek", "minimax-m3"]); const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-reuse-runner-thread"; const SESSION_POLICY_RUN_LOCAL = "hwlab-agentrun-v01-session-runner-reuse"; const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "blocked", "cancelled", "canceled"]); -const DEFAULT_CONVERSATION_CONTEXT_MESSAGE_LIMIT = 8; -const DEFAULT_CONVERSATION_CONTEXT_MESSAGE_TEXT_LIMIT = 900; -const DEFAULT_CONVERSATION_CONTEXT_TOTAL_TEXT_LIMIT = 4_800; const HWLAB_RESOURCE_TOOL_ALIASES = Object.freeze([ Object.freeze({ name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" }) ]); @@ -522,26 +519,12 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }) { const prompt = String(params.message ?? params.prompt ?? "").trim(); - const conversationContext = buildAgentRunConversationContext(params, prompt, traceId); - const commandPrompt = conversationContext.promptPrefix ? `${conversationContext.promptPrefix}\n\n[HWLAB 当前用户消息]\n${prompt}` : prompt; const threadId = safeOpaqueId(params.threadId); return { type: "turn", payload: { - prompt: commandPrompt, + prompt, message: prompt, - ...(conversationContext.messages.length > 0 - ? { - originalPrompt: prompt, - conversationContext: { - source: conversationContext.source, - messageCount: conversationContext.messages.length, - truncated: conversationContext.truncated, - valuesRedacted: true, - messages: conversationContext.messages - } - } - : {}), traceId, conversationId: safeConversationId(params.conversationId) || null, sessionId: sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile), @@ -557,98 +540,6 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }; } -function buildAgentRunConversationContext(params = {}, currentPrompt = "", traceId = "") { - const messages = conversationContextMessages(params, currentPrompt, traceId); - if (messages.length === 0) return { source: "none", messages: [], promptPrefix: "", truncated: false }; - const lines = [ - "[HWLAB 同一会话的已脱敏历史上下文]", - "以下内容来自当前 conversation/session/thread 的上一轮消息;请用它延续对话,不要把本轮当作新会话。" - ]; - for (const message of messages) { - const role = message.role === "assistant" ? "Assistant" : "User"; - lines.push(`${role}: ${message.text}`); - } - return { - source: conversationContextSource(params), - messages, - promptPrefix: lines.join("\n"), - truncated: Boolean(params.conversationContext?.truncated || params.conversationContextTruncated) - }; -} - -function conversationContextMessages(params = {}, currentPrompt = "", traceId = "") { - const rawMessages = Array.isArray(params.conversationContext?.messages) - ? params.conversationContext.messages - : Array.isArray(params.messages) - ? params.messages - : []; - if (rawMessages.length === 0) return []; - const current = normalizeContextText(currentPrompt); - const currentTraceId = safeTraceId(traceId ?? params.traceId); - const normalized = []; - let totalTextLength = 0; - for (const raw of rawMessages) { - const message = normalizeConversationContextMessage(raw); - if (!message) continue; - if (currentTraceId && safeTraceId(raw?.traceId ?? raw?.runnerTrace?.traceId) === currentTraceId) continue; - if (message.text === current) continue; - normalized.push(message); - } - const selected = []; - for (const message of normalized.slice(-DEFAULT_CONVERSATION_CONTEXT_MESSAGE_LIMIT).reverse()) { - if (totalTextLength >= DEFAULT_CONVERSATION_CONTEXT_TOTAL_TEXT_LIMIT) break; - const remaining = DEFAULT_CONVERSATION_CONTEXT_TOTAL_TEXT_LIMIT - totalTextLength; - const text = boundContextText(message.text, Math.min(DEFAULT_CONVERSATION_CONTEXT_MESSAGE_TEXT_LIMIT, remaining)); - if (!text) continue; - selected.push({ ...message, text }); - totalTextLength += text.length; - } - return selected.reverse(); -} - -function normalizeConversationContextMessage(raw) { - if (!raw || typeof raw !== "object") return null; - const role = normalizeConversationContextRole(raw.role ?? raw.author ?? raw.source ?? raw.type); - if (!role) return null; - const text = boundContextText(normalizeContextText(firstNonEmpty(raw.text, raw.content, raw.message, raw.reply?.content, raw.result?.reply?.content)), DEFAULT_CONVERSATION_CONTEXT_MESSAGE_TEXT_LIMIT); - if (!text) return null; - return { - role, - text, - traceId: safeTraceId(raw.traceId ?? raw.runnerTrace?.traceId) || null, - status: firstNonEmpty(raw.status, raw.runnerTrace?.sessionStatus, raw.sessionStatus) || null - }; -} - -function normalizeConversationContextRole(value) { - const role = String(value ?? "").trim().toLowerCase(); - if (["user", "human", "operator"].includes(role)) return "user"; - if (["assistant", "agent", "code-agent", "code_agent", "model"].includes(role)) return "assistant"; - return null; -} - -function conversationContextSource(params = {}) { - return firstNonEmpty(params.conversationContext?.source, params.contextSource, Array.isArray(params.conversationContext?.messages) ? "server" : "request-messages") || "request-messages"; -} - -function normalizeContextText(value) { - return String(value ?? "").replace(/\s+/gu, " ").trim(); -} - -function boundContextText(value, limit) { - const text = redactContextSecrets(normalizeContextText(value)); - if (!text) return ""; - if (text.length <= limit) return text; - return `${text.slice(0, Math.max(0, limit - 15)).trimEnd()} ...[truncated]`; -} - -function redactContextSecrets(value) { - return String(value ?? "") - .replace(/\b(sk|pk|ak|pat|ghp|github_pat|glpat|xox[baprs]?|AIza)[A-Za-z0-9_\-]{8,}\b/gu, "[REDACTED_SECRET]") - .replace(/\b[A-Za-z0-9+/]{32,}={0,2}\b/gu, "[REDACTED_TOKEN]") - .replace(/(token|secret|password|passwd|api[_-]?key|credential)\s*[:=]\s*[^\s,;]+/giu, "$1=[REDACTED]"); -} - function buildAgentRunSteerCommandInput({ params, traceId, steerTraceId, mapped }) { const prompt = String(params.message ?? params.prompt ?? params.text ?? "").trim(); return { @@ -880,7 +771,7 @@ function agentRunFailureAttribution({ code, message, canceled = false } = {}) { return { category: "thread_resume_failed", retryable: true, - userMessage: "AgentRun 复用的 Codex thread 已失效;当前标准是终止本轮并保留原 session 指针,不自动创建 replacement thread。请重新发起新会话。", + userMessage: "AgentRun 复用的 Codex thread 已失效;当前标准是终止本轮并保留原 session 指针,不启动新的 thread/start,也不拼接历史上下文。请重新发起新会话。", summary: message || "AgentRun thread/resume failed for an existing thread." }; } diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index eee7f595..edac8ddc 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -205,16 +205,16 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(body.payload.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse"); const secondTurn = /第二轮/u.test(body.payload.message ?? body.payload.prompt); if (secondTurn) { - assert.match(body.payload.prompt, /HWLAB 同一会话的已脱敏历史上下文/u); - assert.match(body.payload.prompt, /看看device-pod可用性/u); - assert.match(body.payload.prompt, /v0\.2 device-pod 可用性快照/u); - assert.match(body.payload.prompt, /总结我们刚才的对话内容/u); + assert.equal(body.payload.prompt, "AgentRun adapter smoke 第二轮:总结我们刚才的对话内容"); assert.equal(body.payload.message, "AgentRun adapter smoke 第二轮:总结我们刚才的对话内容"); - assert.equal(body.payload.originalPrompt, body.payload.message); - assert.equal(body.payload.conversationContext.source, "account-conversation"); - assert.equal(body.payload.conversationContext.messageCount, 2); - assert.equal(body.payload.conversationContext.valuesRedacted, true); - assert.equal(JSON.stringify(body.payload.conversationContext).includes("test-device-pod-session-token"), false); + assert.equal(Object.hasOwn(body.payload, "originalPrompt"), false); + assert.equal(Object.hasOwn(body.payload, "conversationContext"), false); + assert.equal(Object.hasOwn(body.payload, "messages"), false); + assert.equal(body.payload.prompt.includes("HWLAB 同一会话的已脱敏历史上下文"), false); + assert.equal(body.payload.prompt.includes("看看device-pod可用性"), false); + assert.equal(body.payload.prompt.includes("v0.2 device-pod 可用性快照"), false); + assert.equal(body.payload.prompt.includes("legacy synthetic context must not reach provider"), false); + assert.equal(body.payload.prompt.includes("legacy flat messages must not reach provider"), false); assert.equal(body.payload.prompt.includes("test-device-pod-session-token"), false); assert.equal(body.payload.prompt.includes("sk-test-device-secret"), false); } else { @@ -477,6 +477,15 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte ownerUserId: "usr_agent_owner", ownerRole: "user", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", + conversationContext: { + source: "legacy-client", + messages: [ + { role: "user", text: "legacy synthetic context must not reach provider" } + ] + }, + messages: [ + { role: "assistant", text: "legacy flat messages must not reach provider" } + ], message: "AgentRun adapter smoke 第二轮:总结我们刚才的对话内容" }) }); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index e70d442f..e998cb7b 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -88,11 +88,11 @@ export async function handleCodeAgentChatHttp(request, response, options) { }; const workspaceClaim = await claimWorkbenchWorkspaceTurn({ params: chatParams, options, traceId, response }); if (workspaceClaim?.blocked) return; - const hydratedChatParams = await hydrateCodeAgentConversationContext({ params: chatParams, options, workspaceClaim, traceId }); + const nativeSessionChatParams = stripSyntheticConversationContext(chatParams, traceId, options); if (codeAgentChatShortConnectionRequested(request, params, options)) { submitCodeAgentChatTurn({ - params: hydratedChatParams, + params: nativeSessionChatParams, options, traceId }); @@ -105,8 +105,8 @@ export async function handleCodeAgentChatHttp(request, response, options) { traceId, workspaceId: workspaceClaim?.workspace?.id ?? null, workspaceRevision: workspaceClaim?.workspace?.revision ?? null, - conversationId: safeConversationId(hydratedChatParams.conversationId) || null, - sessionId: safeSessionId(hydratedChatParams.sessionId) || null, + conversationId: safeConversationId(nativeSessionChatParams.conversationId) || null, + sessionId: safeSessionId(nativeSessionChatParams.sessionId) || null, traceUrl, resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`, streamUrl: `${traceUrl}/stream`, @@ -120,9 +120,9 @@ export async function handleCodeAgentChatHttp(request, response, options) { return; } - const payload = await runCodeAgentChat(hydratedChatParams, options); - await recordCodeAgentSessionOwner({ payload, params: hydratedChatParams, options, status: payload.status === "completed" ? "active" : payload.status }); - const responsePayload = annotateOwner(payload, hydratedChatParams); + const payload = await runCodeAgentChat(nativeSessionChatParams, options); + await recordCodeAgentSessionOwner({ payload, params: nativeSessionChatParams, options, status: payload.status === "completed" ? "active" : payload.status }); + const responsePayload = annotateOwner(payload, nativeSessionChatParams); sendJson(response, responsePayload.status === "failed" && responsePayload.error?.code === "invalid_params" ? 400 : 200, responsePayload); } @@ -131,80 +131,18 @@ async function runCodeAgentChat(params, options) { return handleCodeAgentChat(params, await codeAgentChatExecutionOptions(options, params)); } -async function hydrateCodeAgentConversationContext({ params = {}, options = {}, workspaceClaim = null, traceId } = {}) { - if (Array.isArray(params.conversationContext?.messages) && params.conversationContext.messages.length > 0) return params; - const directMessages = Array.isArray(params.messages) ? params.messages : []; - if (directMessages.length > 0) return { ...params, conversationContext: codeAgentConversationContextPayload({ messages: directMessages, source: "request-messages" }) }; - const workspaceMessages = messagesFromWorkbenchWorkspace(workspaceClaim?.workspace); - if (workspaceMessages.length > 0) { - return attachCodeAgentConversationContext(params, workspaceMessages, "workbench-workspace", traceId, options); - } - const conversationId = safeConversationId(params.conversationId ?? workspaceClaim?.workspace?.selectedConversationId); - if (!options.actor || !conversationId || typeof options.accessController?.visibleConversationForActor !== "function") return params; - try { - const conversation = await options.accessController.visibleConversationForActor(options.actor, conversationId, params.projectId ?? workspaceClaim?.workspace?.projectId); - const messages = Array.isArray(conversation?.messages) ? conversation.messages : Array.isArray(conversation?.snapshot?.messages) ? conversation.snapshot.messages : []; - return messages.length > 0 ? attachCodeAgentConversationContext(params, messages, "account-conversation", traceId, options) : params; - } catch (error) { +function stripSyntheticConversationContext(params = {}, traceId, options = {}) { + if (Array.isArray(params.conversationContext?.messages) || Array.isArray(params.messages)) { (options.traceStore ?? defaultCodeAgentTraceStore).append(traceId, { - type: "conversation-context", - status: "degraded", - label: "conversation-context:lookup-failed", - errorCode: error?.code ?? "conversation_context_lookup_failed", - message: "Stored conversation context lookup failed; this turn will continue with threadId/sessionId only.", + type: "synthetic-context", + status: "ignored", + label: "synthetic-context:ignored", + message: "Synthetic conversation context was stripped; Code Agent continuation uses only Codex stdio native thread/resume and turn/start.", valuesPrinted: false }); - return params; } -} - -function messagesFromWorkbenchWorkspace(workspace = null) { - const json = workspace?.workspace && typeof workspace.workspace === "object" ? workspace.workspace : {}; - if (Array.isArray(json.messages)) return json.messages; - if (Array.isArray(workspace?.messages)) return workspace.messages; - return []; -} - -function attachCodeAgentConversationContext(params, messages, source, traceId, options = {}) { - const payload = codeAgentConversationContextPayload({ messages, source }); - if (payload.messages.length === 0) return params; - (options.traceStore ?? defaultCodeAgentTraceStore).append(traceId, { - type: "conversation-context", - status: "running", - label: "conversation-context:attached", - message: `Attached ${payload.messages.length} redacted prior conversation messages for AgentRun thread continuation.`, - source, - messageCount: payload.messages.length, - valuesPrinted: false - }); - return { ...params, conversationContext: payload }; -} - -function codeAgentConversationContextPayload({ messages = [], source = "unknown" } = {}) { - const selected = Array.isArray(messages) ? messages.slice(-12).map(publicConversationContextMessage).filter(Boolean) : []; - return { - source, - messages: selected, - truncated: Array.isArray(messages) && messages.length > selected.length, - valuesRedacted: true - }; -} - -function publicConversationContextMessage(message) { - if (!message || typeof message !== "object") return null; - const role = textValue(message.role); - const text = textValue(firstNonEmptyValue(message.text, message.content, message.message, message.reply?.content)); - if (!role || !text) return null; - return { - role, - text, - status: textValue(message.status), - traceId: safeTraceId(message.traceId ?? message.runnerTrace?.traceId) || null, - sessionId: safeSessionId(message.sessionId) || null, - threadId: safeOpaqueId(message.threadId) || null, - createdAt: textValue(message.createdAt), - updatedAt: textValue(message.updatedAt) - }; + const { conversationContext, conversationContextTruncated, contextSource, messages, ...nativeParams } = params; + return nativeParams; } async function codeAgentChatExecutionOptions(options = {}, params = {}) { diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 7608d572..efd12680 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -4350,7 +4350,8 @@ export async function runDevCloudWorkbenchSessionContinuityFixtureSmoke(options conversationId: body?.conversationId ?? null, sessionId: body?.sessionId ?? null, threadId: body?.threadId ?? null, - traceId: body?.traceId ?? null + traceId: body?.traceId ?? null, + hasConversationContext: Boolean(body?.conversationContext) }); }); await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 }); @@ -4394,6 +4395,7 @@ export async function runDevCloudWorkbenchSessionContinuityFixtureSmoke(options secondRequest?.sessionId === "ses_source_fixture_continuity" && secondRequest?.threadId === "thread_source_fixture_continuity" ); + const nativeContextOk = secondRequest?.hasConversationContext === false; const retryOk = Boolean( retryRequest?.message === failedRequest?.message && retryRequest?.conversationId === failedRequest?.conversationId && @@ -4404,8 +4406,8 @@ export async function runDevCloudWorkbenchSessionContinuityFixtureSmoke(options const checks = [ { id: "local-agent-session-continuity-two-turns", - status: continuityOk && first.status === "pass" && second.status === "pass" ? "pass" : "blocked", - summary: "第二轮 /v1/agent/chat 请求复用第一轮建立的 conversationId、sessionId 和 threadId。", + status: continuityOk && nativeContextOk && first.status === "pass" && second.status === "pass" ? "pass" : "blocked", + summary: "第二轮 /v1/agent/chat 请求复用第一轮建立的 conversationId、sessionId 和 threadId,且不携带 synthetic conversationContext。", observations: { firstRequest, secondRequest, first, second } }, { diff --git a/web/hwlab-cloud-web/code-agent-facts.ts b/web/hwlab-cloud-web/code-agent-facts.ts index 30acf2dc..3ea51b8d 100644 --- a/web/hwlab-cloud-web/code-agent-facts.ts +++ b/web/hwlab-cloud-web/code-agent-facts.ts @@ -251,7 +251,7 @@ export function codeAgentRuntimePathFromMessage(message) { summary = `Codex app-server/provider 返回 failureKind=provider-invalid-tool-call,表示上游 tool-call arguments JSON 无效;不是 HWLAB device-pod 或 Cloud API 端点失败。${failureMessage ? ` 原始摘要:${failureMessage}` : ""}`; } else if (failureKind === "thread-resume-failed") { label = "BLOCKED:Codex thread resume 失败"; - summary = `AgentRun 复用的 Codex thread 已失效,当前标准是不自动创建 replacement thread;本轮以 thread-resume-failed 终止。${failureMessage ? ` 原始摘要:${failureMessage}` : ""}`; + summary = `AgentRun 复用的 Codex thread 已失效,当前标准是不启动新的 thread/start,也不拼接历史上下文;本轮以 thread-resume-failed 终止。${failureMessage ? ` 原始摘要:${failureMessage}` : ""}`; } else { label = "BLOCKED:Codex app-server 终态未完成"; summary = `repo-owned Codex app-server stdio 返回 terminalStatus=${terminalStatus};本次不标为完整完成。`;