Merge pull request #704 from pikasTech/fix/issue701-native-codex-stdio-continuity

fix: use native Codex stdio continuation
This commit is contained in:
Lyon
2026-06-02 16:23:40 +08:00
committed by GitHub
8 changed files with 47 additions and 205 deletions
+1
View File
@@ -26,6 +26,7 @@
- CLI 本地登录态必须支持 `--profile NAME` 隔离,同一 base URL 下不同 profile 写入 `.state/hwlab-cli/profiles/<base-url-hash>/<profile>.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 <traceId>`,让 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` 才跳过这一默认恢复路径。
+3 -3
View File
@@ -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
+2 -1
View File
@@ -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 拼接来源。
## 内部架构
+2 -111
View File
@@ -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."
};
}
+18 -9
View File
@@ -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 第二轮:总结我们刚才的对话内容"
})
});
+15 -77
View File
@@ -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 = {}) {
@@ -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 }
},
{
+1 -1
View File
@@ -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 = "BLOCKEDCodex 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 = "BLOCKEDCodex app-server 终态未完成";
summary = `repo-owned Codex app-server stdio 返回 terminalStatus=${terminalStatus};本次不标为完整完成。`;