fix: 统一 AgentRun threadId 上下文连续性

This commit is contained in:
Codex
2026-06-02 10:19:34 +08:00
parent da73598d60
commit 3c6edb73a1
12 changed files with 158 additions and 42 deletions
+4 -5
View File
@@ -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/<traceId>`
终态归一,再回放 `/trace`。旧的 `running` 消息不能只因为 localStorage restore 被降级成
+5
View File
@@ -24,6 +24,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`
- 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 <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 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 <traceId> --message "重试上一条" --provider-profile deepseek --no-wait`,确认 CLI 先访问 `/v1/agent/chat/inspect?traceId=<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。
@@ -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。
@@ -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/启动新 runnerresult 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。
+37 -5
View File
@@ -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 : {};
+12 -1
View File
@@ -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, {
@@ -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
};
+5 -5
View File
@@ -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";
+17 -14
View File
@@ -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);
+33
View File
@@ -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;
+10 -11
View File
@@ -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: {
+1 -1
View File
@@ -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);