From 1830df467e6381360a831cc6508fac9739b5340a Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 6 Jun 2026 13:08:00 +0800 Subject: [PATCH] fix: unify AgentRun trace result authority --- AGENTS.md | 1 + docs/reference/spec-v02-code-agent-trace.md | 101 +++++ internal/cloud/access-control.test.ts | 15 + internal/cloud/access-control.ts | 2 +- internal/cloud/code-agent-agentrun-adapter.ts | 387 ++++++++---------- internal/cloud/server-agent-chat.test.ts | 314 +++++++++++++- internal/cloud/server-code-agent-http.ts | 37 +- 7 files changed, 613 insertions(+), 244 deletions(-) create mode 100644 docs/reference/spec-v02-code-agent-trace.md diff --git a/AGENTS.md b/AGENTS.md index cb14ba49..8b831eb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - v0.2 `hwlab-cloud-web` 浏览器工作台规格:[docs/reference/spec-v02-hwlab-cloud-web.md](docs/reference/spec-v02-hwlab-cloud-web.md)。 - v0.2 Provider API Key 管理页、HWLAB 鉴权后委托 AgentRun 后端和 DeepSeek 官方链路验证规格:[docs/reference/spec-v02-provider-management.md](docs/reference/spec-v02-provider-management.md)。 - v0.2 Code Agent 由 `hwlab-cloud-api` 接入 AgentRun v0.1 共享执行基础设施,不再保留 HWLAB 自有 agent manager/worker 控制面:[docs/reference/agentrun-code-agent-dispatch.md](docs/reference/agentrun-code-agent-dispatch.md)。 +- v0.2 Code Agent trace/result/final response 的权威数据源、派生缓存边界和 Web/CLI trace 展示路径:[docs/reference/spec-v02-code-agent-trace.md](docs/reference/spec-v02-code-agent-trace.md)。 - v0.2 `hwlab-agent-skills` 技能包服务规格:[docs/reference/spec-v02-hwlab-agent-skills.md](docs/reference/spec-v02-hwlab-agent-skills.md)。 - v0.2 `hwlab-cli` 固定 repo 短连接 client 规格:[docs/reference/spec-v02-hwlab-cli.md](docs/reference/spec-v02-hwlab-cli.md)。 - HWPOD Harness 快速迭代规格,定义 workspace-local `hwpod-spec`、`hwpod-cli`、`hwpod-ctl`、`hwpod-compiler-cli`、`hwpod-node-ops` 和 `hwpod-node`:[docs/reference/spec-hwpod-harness.md](docs/reference/spec-hwpod-harness.md)。 diff --git a/docs/reference/spec-v02-code-agent-trace.md b/docs/reference/spec-v02-code-agent-trace.md new file mode 100644 index 00000000..7417b1ce --- /dev/null +++ b/docs/reference/spec-v02-code-agent-trace.md @@ -0,0 +1,101 @@ +# v0.2 Code Agent Trace 规格 + +本文定义 HWLAB v0.2 Code Agent trace、result 和 Cloud Web trace 展示的数据权威、读写路径和回归判定。Trace 是 Workbench 最复杂的用户可见状态面;实现必须先收敛 source of truth,再生成派生 UI/cache,不得通过叠加 fallback、多路径或兼容快照掩盖状态污染。 + +## 身份模型 + +| 身份 | 归属 | 语义 | +| --- | --- | --- | +| `traceId` | HWLAB Cloud API | 用户一次 Code Agent turn 的可见追踪 ID,也是 result/trace API 的主键。 | +| `conversationId` | HWLAB Workbench | 用户可见对话容器;只用于业务上下文和 UI 分组。 | +| `sessionId` | HWLAB Code Agent session | 用户显式创建或选择的业务 session,绑定 owner、provider profile 和权限。 | +| `threadId` | HWLAB/AgentRun/Codex | 会话连续性的唯一标准字段;不得新增历史 thread 别名或拼 prompt 替代。 | +| `runId` | AgentRun | 可复用执行壳;同一个 run 可承载同一 session 的多条 command。 | +| `commandId` | AgentRun | 单个 turn 的执行 identity;同一 `runId` 下每个 `traceId` 必须映射到唯一 command。 | +| `runnerId` / `jobName` | AgentRun / k8s | runner 尝试身份,只能作为执行诊断,不代表业务 session。 | + +`traceId -> AgentRun commandId` 是 AgentRun 接入后的核心映射。`runId` 只能说明执行壳复用,不能单独说明某个用户 turn 的 final response。Cloud Web、CLI 和 issue closeout 必须同时展示业务身份和执行身份,避免把共享 run 的上一个 command 误认为当前 trace。 + +## 权威数据源 + +- **AgentRun command registry 是 completed AgentRun trace 的执行权威**:Cloud API 必须通过 `GET /api/v1/runs/{runId}/commands?afterSeq=0&limit=100` 用 `idempotencyKey === traceId` 或 `payload.traceId === traceId` 找到当前 trace 的 command。 +- **AgentRun command result 是 final response 权威**:找到 command 后,Cloud API 必须通过 `GET /api/v1/runs/{runId}/commands/{commandId}/result` 生成 `reply.content`、`finalResponse`、`providerTrace` 和 completed result payload。 +- **AgentRun events 是 trace row 权威**:Cloud API 从 `GET /api/v1/runs/{runId}/events` 读取事件,并按当前 `commandId` 过滤 assistant/tool/terminal rows。旧 command 的 assistant/tool/terminal 事件不得进入当前 trace 的可读事件列表。 +- **`agent_sessions` 是业务 session 和 seed store,不是 final response authority**:它保存 owner、project、conversation/session/thread、lastTraceId、AgentRun run seed 和必要派生证据;读取 terminal result 时不得把 `session.finalResponse`、`session.traceSummary` 或旧 `traceResults` 当成 completed 文本权威。 +- **内存 `codeAgentChatResults` 是短连接缓存,不是 terminal authority**:running 状态可用它承接轮询状态;terminal AgentRun result 再次读取时必须重新走 command registry/result 或使用由该路径刚生成的新 payload。 +- **`traceStore` 是 live event buffer,不是历史 final authority**:它服务增量轮询和短期 trace 展示;事件过期时可以报告 missing/expired,但不能用别的 trace 的 final response 填补。 + +## 写入流程 + +1. Cloud Web 或 `hwlab-cli client` 通过同源 `POST /v1/agent/chat` 提交 turn;请求必须携带显式选中的 `conversationId/sessionId/threadId` 和 provider profile 语义。 +2. Cloud API 认证 actor,按 session owner 和 tool capability 装配 AgentRun 请求;HWPOD/runner 使用的用户 API key 只允许来自 owner 的 `HWLAB_API_KEY`,浏览器 session、cookie 或其他 env 别名不得成为 CLI/runner key 来源。 +3. Cloud API 创建或复用 AgentRun `runId`,再创建新 command;command `idempotencyKey` 和 `payload.traceId` 必须等于本次 `traceId`,`payload.conversationId`、`payload.hwlabSessionId`、`payload.threadId` 和 `payload.providerProfile` 必须保留。 +4. Cloud API 写入 session seed:业务 session、lastTraceId、AgentRun `runId`、可选当前 `commandId`、provider profile 和 owner 归属。该 seed 只用于后续找到 run,不承诺 final response 正确。 +5. 初始 result 返回 `running/accepted`,Web 和 CLI 进入短连接 result/trace 轮询。 + +写入路径不得把浏览器历史 messages、旧 trace final response、旧 command tail 或 session top-level snapshot 拼入新 command payload。历史只用于 UI 展示和排障,不是模型上下文,也不是新 result 的数据来源。 + +## Result 读取流程 + +`GET /v1/agent/chat/result/{traceId}` 是 terminal result 的唯一用户入口。AgentRun adapter 启用时,Cloud API 必须执行以下顺序: + +1. 校验 `traceId` 和 session owner;普通用户只能读自己的 trace,admin 按授权读。 +2. 从内存缓存或 `agent_sessions` 读取最小 AgentRun seed,至少需要 `runId`;如果没有 seed,返回 not found,不猜测其他 session。 +3. 对 terminal 或可能 terminal 的 AgentRun result,必须用 AgentRun command registry 按 `traceId` 解析当前 `commandId`。不得相信缓存里的 `agentRun.commandId`、`providerTrace.commandId`、`traceSummary.agentRun.commandId` 或 top-level `lastTraceId` 作为最终映射。 +4. 找到 command 后,读取 command result,并从 command result 重建 `reply`、`finalResponse`、`providerTrace`、`traceSummary` 和 `agentRun` 字段。 +5. 写回内存缓存和 session 派生证据时,只能写刚由 command result 生成的 payload;旧 `finalResponse`、旧 `traceSummary` 和旧 `traceResults` 不得参与 merge 覆盖。 +6. 如果 command registry 找不到该 `traceId` 对应 command,返回结构化错误,例如 `agentrun_trace_command_not_found`;不得回退到旧缓存、top-level snapshot、latest trace 或其他 command result。 + +Running 轮询可以返回 `202` 和当前 runnerTrace;这只是“尚未 terminal”的轮询语义,不是 final fallback。Completed / failed / canceled / blocked 等 terminal 状态必须经过同一 command registry/result 路径确认。 + +## Trace 读取与渲染流程 + +`GET /v1/agent/chat/trace/{traceId}` 服务完整 trace 回放,Cloud Web 与 `hwlab-cli client agent trace --render web` 必须共享同一 row 转换语义: + +1. Cloud API 先用 result 同一路径解析当前 `traceId -> commandId`;对于 AgentRun trace,events 必须按该 `commandId` 过滤。 +2. 可读 rows 至少覆盖 request/setup、tool call、assistant message、terminal/result 和 blocker。低价值 AgentRun backend echo、状态 echo、terminal boilerplate 和重复噪声可被 row renderer 去噪,但原始 trace JSON 仍应通过 `--full` 或下载保留。 +3. Web 默认显示完整可读事件列表。Result polling 返回的 compacted head-tail runnerTrace 不能冒充完整 trace;发现 `eventsCompacted=true` 时必须继续请求 `/trace/{traceId}`。 +4. 如果完整 trace 已过期或缺失,UI 必须显式展示 missing/expired,并提供 result/inspect 可用证据;不得用最新 trace 或 session final response 填补历史 trace。 +5. 同一 run 多 command 时,旧 command 的 assistant/tool/terminal rows 不能挤到新 command 的 final response 或 trace 尾部。取消/失败前的有价值上下文应作为脱敏 conversation facts 或当前 command 自身事件展示,不通过串线实现。 + +## 派生缓存边界 + +允许存在的派生状态只有两个用途:降低轮询开销和提升 UI 可见性。派生状态必须可被权威路径重建,且不得成为 terminal result 的替代 authority。 + +| 派生状态 | 允许用途 | 禁止用途 | +| --- | --- | --- | +| `codeAgentChatResults` | 保存刚提交的 running result、刚由 command result 生成的 terminal payload。 | 在 terminal 读取时跳过 command registry;返回旧 final response。 | +| `session.traceResults` | 保存按 traceId 写回的审计证据,加快 inspect/list 展示。 | 覆盖 command result;把旧 command 文本当成当前 trace final。 | +| `session.agentRun` | 保存最近或可复用 run seed。 | 证明当前 trace 的 commandId 或 final response。 | +| `session.finalResponse` / `session.traceSummary` | 只作为最近消息的派生展示,必须标明 traceId。 | 历史 result/trace 的 authority;修复其他 trace 时覆盖 latest。 | +| `runnerTrace` compact window | result 轮询里的轻量进度。 | Web 完整 trace 列表;历史 trace closeout 证据。 | + +当派生状态和 AgentRun command registry 冲突时,以 command registry 和 command result 为准;冲突的派生状态应被下一次权威读取覆盖或忽略,不新增兼容分支。 + +## 禁止路径 + +- 不得为 trace/result 增加 parallel final response path、legacy mode、feature flag、旧 top-level fallback 或“查不到 command 就用 latest/cached result”的分支。 +- 不得把 `runId` 当成 trace authority;共享 run 下必须使用 `commandId` 分割事件和 final response。 +- 不得把 `provider=agentrun-v01` 当成真实模型/provider;`agentrun-v01` 只属于 adapter/runner infrastructure 字段。 +- 不得在 Web、CLI 或 closeout 中把 `AgentRun result is ready`、terminal status echo、boilerplate completion row 或 placeholder status 当成 assistant final response。 +- 不得通过拼接浏览器历史消息、旧 trace rows 或旧 summary 来制造 continuity;continuity 只由标准 `threadId` 和 AgentRun/Codex 原生 resume 证明。 +- 不得新增 `HWLAB_API_KEY` 以外的 CLI/runner API key 来源,也不得让 Web session/cookie 成为 runner key fallback。 + +## 回归测试要求 + +源码层测试必须覆盖以下最小场景: + +- 同一 `runId` 下两个 `traceId` / `commandId`,读取第一条历史 trace 时不显示第二条 command 的 assistant/tool/terminal rows。 +- 第二条 trace 的 session top-level、`traceResults` 或内存 cache 被污染成第一条 command/final 时,`GET /result/{secondTraceId}` 必须通过 command registry/result 返回第二条 command 的 final response。 +- 已 completed 的 AgentRun 内存缓存也必须走 command registry/result;不能只同步 running。 +- command registry 找不到目标 `traceId` 时返回结构化错误,不返回旧 cache/latest/top-level。 +- Web renderer 的纯逻辑测试覆盖 request/setup、tool call、assistant markdown、completion row、noise count 和 compacted trace 自动回放条件。 + +真实入口验收必须走目标 lane 的 public Cloud Web origin 或 Web 等价 CLI:`POST /v1/agent/chat`、`GET /v1/agent/chat/result/{traceId}`、`GET /v1/agent/chat/trace/{traceId}` 和 `inspect/session list`。仅有单测、PR merge、PipelineRun 或源码证据不能关闭 trace/result 类 issue。 + +## 相关文档 + +- [spec-v02-hwlab-cloud-web.md](spec-v02-hwlab-cloud-web.md):Cloud Web 工作台、trace renderer 和 Web/CLI 同路径要求。 +- [agentrun-code-agent-dispatch.md](agentrun-code-agent-dispatch.md):AgentRun run/command/resource bundle/credential 装配边界。 +- [code-agent-chat-readiness.md](code-agent-chat-readiness.md):Code Agent 真实 completed 判定和 provider readiness。 +- [spec-user-access.md](spec-user-access.md):Code Agent session owner、trace/result/cancel 权限边界。 diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index e882e7ab..6001e9f5 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -452,6 +452,11 @@ test("workbench workspace status clears completed AgentRun active trace on read" { id: "evt_status_done", runId: "run_workspace_status", seq: 1, type: "terminal_status", payload: { commandId: "cmd_workspace_status", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:01.000Z" } ] }); } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_status/commands") { + return send({ items: [ + { id: "cmd_workspace_status", runId: "run_workspace_status", state: "completed", type: "turn", seq: 1, idempotencyKey: "trc_issue664_status_done", payload: { traceId: "trc_issue664_status_done", conversationId: "cnv_issue664_status", hwlabSessionId: "ses_issue664_status", threadId: "thread-issue-664", providerProfile: "deepseek" } } + ] }); + } if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_status/commands/cmd_workspace_status/result") { return send({ runId: "run_workspace_status", @@ -582,6 +587,11 @@ test("workbench workspace terminal status sync preserves a newer selected conver { id: "evt_issue808_done", runId: "run_issue808_old", seq: 1, type: "terminal_status", payload: { commandId: "cmd_issue808_old", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:01.000Z" } ] }); } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_issue808_old/commands") { + return send({ items: [ + { id: "cmd_issue808_old", runId: "run_issue808_old", state: "completed", type: "turn", seq: 1, idempotencyKey: "trc_issue808_old_done", payload: { traceId: "trc_issue808_old_done", conversationId: "cnv_issue808_old", hwlabSessionId: "ses_issue808_old", threadId: "thread-issue-808-old", providerProfile: "deepseek" } } + ] }); + } if (request.method === "GET" && url.pathname === "/api/v1/runs/run_issue808_old/commands/cmd_issue808_old/result") { return send({ runId: "run_issue808_old", @@ -872,6 +882,11 @@ test("workbench workspace status repairs terminal selected conversation after ac if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_repair/events") { return send({ items: [] }); } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_repair/commands") { + return send({ items: [ + { id: "cmd_workspace_repair", runId: "run_workspace_repair", state: "completed", type: "turn", seq: 1, idempotencyKey: "trc_issue664_repair_done", payload: { traceId: "trc_issue664_repair_done", conversationId: "cnv_issue664_repair", hwlabSessionId: "ses_issue664_repair", threadId: "thread-issue-664-repair", providerProfile: "deepseek" } } + ] }); + } if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_repair/commands/cmd_workspace_repair/result") { return send({ runId: "run_workspace_repair", diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 89c276cb..b0487e1f 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -1344,7 +1344,7 @@ class AccessController { const persisted = cached ? null : await loadPersistedAgentRunResult(safeTraceId, options); const current = cached ?? persisted ?? null; if (!current || !canActorReadWorkspaceTrace(current, actor)) return null; - const synced = current?.agentRun?.runId && current.status === "running" + const synced = current?.agentRun?.runId ? await syncAgentRunChatResult({ traceId: safeTraceId, currentResult: current, options, traceStore: options.traceStore }) : { result: current }; const result = synced.result ?? current; diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 862f05d8..2788a21b 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -428,8 +428,9 @@ function isAgentRunCommandAlreadyClaimed(error) { return statusCode === 409 && /command\s+[^\s]+\s+is not pending:/u.test(message); } -export async function syncAgentRunChatResult({ traceId, currentResult = null, options = {}, traceStore = defaultCodeAgentTraceStore }) { - const mapped = currentResult ?? await loadPersistedAgentRunResult(traceId, options); +export async function syncAgentRunChatResult({ traceId, currentResult = null, options = {}, traceStore = defaultCodeAgentTraceStore, appendResultEvent = true }) { + const initial = currentResult ?? await loadPersistedAgentRunResult(traceId, options); + const mapped = initial ? await resolveAgentRunTraceCommandMapping({ traceId, mapped: initial, options }) : initial; if (!mapped?.agentRun?.runId || !mapped?.agentRun?.commandId) return { result: currentResult, runnerTrace: traceStore.snapshot(traceId), found: Boolean(currentResult) }; const env = options.env ?? process.env; const fetchImpl = options.fetchImpl ?? globalThis.fetch; @@ -454,13 +455,70 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op valuesPrinted: false }; const base = { ...mapped, agentRun: nextMapping, updatedAt: nowIso(options.now) }; - const payload = agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }); + const payload = agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId, appendResultEvent }); options.codeAgentChatResults?.set?.(traceId, payload); return { result: payload, runnerTrace: payload.runnerTrace ?? traceStore.snapshot(traceId), found: true }; } +async function resolveAgentRunTraceCommandMapping({ traceId, mapped, options = {} }) { + const safeId = safeTraceId(traceId); + const agentRun = mapped?.agentRun && typeof mapped.agentRun === "object" ? mapped.agentRun : null; + if (!safeId || !agentRun?.runId) return mapped; + const agentRunTraceId = safeTraceId(agentRun.traceId); + const providerTraceId = safeTraceId(agentRun.providerTrace?.traceId); + const traceSummaryCommandId = text(mapped?.traceSummary?.agentRun?.commandId); + const commandId = text(agentRun.commandId); + const traceFieldsMatch = agentRunTraceId === safeId && providerTraceId === safeId && (!traceSummaryCommandId || traceSummaryCommandId === commandId); + if (mapped.status === "running" && traceFieldsMatch) return mapped; + const env = options.env ?? process.env; + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const managerUrl = resolveAgentRunManagerUrl(env, agentRun.managerUrl); + const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000); + const command = await findAgentRunCommandForTrace({ fetchImpl, managerUrl, timeoutMs, runId: agentRun.runId, traceId: safeId }); + if (!command?.id) { + throw Object.assign(new Error(`AgentRun command registry has no command for ${safeId} in run ${agentRun.runId}`), { + code: "agentrun_trace_command_not_found", + statusCode: 404, + traceId: safeId, + runId: agentRun.runId + }); + } + if (command.id === commandId) { + return { + ...mapped, + finalResponse: null, + traceSummary: null, + agentRun: { + ...agentRun, + traceId: safeId, + lastSeq: 0, + providerTrace: null, + commandState: command.state ?? agentRun.commandState ?? null, + updatedAt: nowIso(options.now), + valuesPrinted: false + } + }; + } + return { + ...mapped, + finalResponse: null, + traceSummary: null, + agentRun: { + ...agentRun, + commandId: command.id, + traceId: safeId, + lastSeq: 0, + providerTrace: null, + commandState: command.state ?? agentRun.commandState ?? null, + updatedAt: nowIso(options.now), + valuesPrinted: false + } + }; +} + export async function refreshAgentRunTrace({ traceId, result = null, options = {}, traceStore = defaultCodeAgentTraceStore }) { - const mapped = result ?? await loadPersistedAgentRunResult(traceId, options); + const initial = result ?? await loadPersistedAgentRunResult(traceId, options); + const mapped = initial ? await resolveAgentRunTraceCommandMapping({ traceId, mapped: initial, options }) : initial; if (!mapped?.agentRun?.runId) return traceStore.snapshot(traceId); const env = options.env ?? process.env; const fetchImpl = options.fetchImpl ?? globalThis.fetch; @@ -609,141 +667,48 @@ export async function loadPersistedAgentRunResult(traceId, options = {}) { const safeId = safeTraceId(traceId); if (!safeId || typeof options.accessController?.getAgentSessionByTraceId !== "function") return null; const session = await options.accessController.getAgentSessionByTraceId(safeId); - const traceEvidence = validAgentSessionTraceEvidence(session, safeId); - const latestTraceId = safeTraceId(session?.lastTraceId ?? session?.session?.lastTraceId ?? session?.session?.traceId); - const topLevelAgentRun = session?.session?.agentRun && typeof session.session.agentRun === "object" ? session.session.agentRun : null; - const agentRunTraceId = safeTraceId(topLevelAgentRun?.traceId); - const topLevelFinalTraceId = safeTraceId(session?.session?.finalResponse?.traceId ?? session?.session?.traceSummary?.traceId); - const topLevelConflictsTrace = (agentRunTraceId && agentRunTraceId !== safeId) || (topLevelFinalTraceId && topLevelFinalTraceId !== safeId); - const mayUseTopLevelAgentRun = !topLevelConflictsTrace && (latestTraceId === safeId || agentRunTraceId === safeId || topLevelFinalTraceId === safeId || (!latestTraceId && !agentRunTraceId && !topLevelFinalTraceId)); - const topLevelTraceEvidence = mayUseTopLevelAgentRun ? agentSessionTopLevelTraceEvidence(session, safeId) : null; - if (!traceEvidence && topLevelTraceEvidence && ambiguousTopLevelAgentRunTrace(topLevelAgentRun)) { - const repaired = await repairPersistedAgentRunResultFromRunCommands({ traceId: safeId, session, options }).catch(() => null); - if (repaired) return repaired; - } - const agentRun = traceEvidence?.agentRun ?? (topLevelTraceEvidence ? topLevelAgentRun : null); - const restored = persistedAgentRunResultFromSession({ traceId: safeId, session, agentRun, traceEvidence, options }); - if (restored) return restored; - return await repairPersistedAgentRunResultFromRunCommands({ traceId: safeId, session, options }); -} - -function persistedAgentRunResultFromSession({ traceId, session, agentRun, traceEvidence, options = {} }) { - if (!agentRun || typeof agentRun !== "object" || !agentRun.runId || !agentRun.commandId) return null; - if (!agentRunEvidenceMatchesTrace(agentRun, traceId, traceEvidence)) return null; - const status = firstNonEmpty(traceEvidence?.status, session?.status === "canceled" ? "canceled" : null, agentRun.terminalStatus, agentRun.commandState, "running"); + const agentRun = agentRunSeedFromSession(session, safeId); + if (!agentRun?.runId) return null; + const backendProfile = firstNonEmpty(agentRun.backendProfile, "deepseek"); + const status = firstNonEmpty(session?.status === "canceled" ? "canceled" : null, agentRun.terminalStatus, agentRun.commandState, agentRun.status, "running"); return { accepted: true, status, shortConnection: true, - traceId, - conversationId: safeConversationId(traceEvidence?.conversationId ?? session?.conversationId) || agentRun.conversationId || null, - sessionId: safeSessionId(traceEvidence?.sessionId ?? session?.id) || agentRun.sessionId || null, - threadId: safeOpaqueId(traceEvidence?.threadId ?? session?.threadId) || agentRun.threadId || null, - messageId: traceEvidence?.messageId ?? session?.session?.messageId ?? `msg_${traceId.slice(4)}`, - createdAt: traceEvidence?.createdAt ?? session?.startedAt ?? session?.updatedAt ?? nowIso(options.now), - updatedAt: traceEvidence?.updatedAt ?? session?.updatedAt ?? nowIso(options.now), + traceId: safeId, + conversationId: safeConversationId(session?.conversationId) || agentRun.conversationId || null, + sessionId: safeSessionId(session?.id) || agentRun.sessionId || null, + threadId: safeOpaqueId(session?.threadId) || agentRun.threadId || null, + messageId: `msg_${safeId.slice(4)}`, + createdAt: session?.startedAt ?? session?.updatedAt ?? nowIso(options.now), + updatedAt: session?.updatedAt ?? nowIso(options.now), ownerUserId: session?.ownerUserId, - provider: providerForBackendProfile(agentRun.backendProfile ?? "deepseek"), - model: modelForBackendProfile(agentRun.backendProfile, options.env ?? process.env), - backend: backendForBackendProfile(agentRun.backendProfile ?? "deepseek"), - infrastructureBackend: `agentrun-v01/${agentRun.backendProfile ?? "deepseek"}`, + ownerRole: session?.ownerRole, + provider: providerForBackendProfile(backendProfile), + model: modelForBackendProfile(backendProfile, options.env ?? process.env), + backend: backendForBackendProfile(backendProfile), + infrastructureBackend: `agentrun-v01/${backendProfile}`, capabilityLevel: AGENTRUN_CAPABILITY_LEVEL, sessionMode: AGENTRUN_SESSION_MODE, implementationType: AGENTRUN_IMPLEMENTATION_TYPE, - finalResponse: traceEvidence?.finalResponse ?? session?.session?.finalResponse ?? null, - traceSummary: traceEvidence?.traceSummary ?? session?.session?.traceSummary ?? null, - agentRun: { ...agentRun, adapter: ADAPTER_ID, valuesPrinted: false }, + agentRun: { ...agentRun, adapter: ADAPTER_ID, traceId: safeId, commandId: text(agentRun.commandId) || null, valuesPrinted: false }, valuesPrinted: false }; } -async function repairPersistedAgentRunResultFromRunCommands({ traceId, session, options = {} }) { - const seedAgentRun = session?.session?.agentRun && typeof session.session.agentRun === "object" ? session.session.agentRun : null; - if (!seedAgentRun?.runId) return null; - const env = options.env ?? process.env; - const fetchImpl = options.fetchImpl ?? globalThis.fetch; - const managerUrl = resolveAgentRunManagerUrl(env, seedAgentRun.managerUrl); - const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000); - const commands = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(seedAgentRun.runId)}/commands?afterSeq=0&limit=100`, { method: "GET", timeoutMs }); - const command = (Array.isArray(commands?.items) ? commands.items : []).find((item) => agentRunCommandMatchesTrace(item, traceId)); - if (!command?.id) return null; - const [result, eventsResponse] = await Promise.all([ - agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(seedAgentRun.runId)}/commands/${encodeURIComponent(command.id)}/result`, { method: "GET", timeoutMs }), - agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(seedAgentRun.runId)}/events?afterSeq=0&limit=500`, { method: "GET", timeoutMs }).catch(() => ({ items: [] })) - ]); - const rawEvents = Array.isArray(eventsResponse?.items) ? eventsResponse.items : []; - const commandSeqs = rawEvents - .filter((event) => agentRunEventCommandId(event) === command.id) - .map((event) => Number(event?.seq ?? 0)) - .filter(Number.isFinite); - const firstSeq = commandSeqs.length ? Math.min(...commandSeqs) : 0; - const afterSeq = firstSeq > 0 ? firstSeq - 1 : 0; - const endSeq = Math.max(0, ...commandSeqs); - const traceEvents = rawEvents.filter((event) => agentRunEventBelongsToTrace(event, { currentCommandId: command.id, afterSeq, endSeq })); - const now = nowIso(options.now); - const backendProfile = firstNonEmpty(seedAgentRun.backendProfile, result?.backendProfile, command?.payload?.providerProfile, "deepseek"); - const agentRun = { - ...seedAgentRun, - adapter: ADAPTER_ID, - managerUrl, - backendProfile, - runId: result?.runId ?? seedAgentRun.runId, - commandId: command.id, - attemptId: result?.attemptId ?? seedAgentRun.attemptId ?? null, - runnerId: result?.runnerId ?? seedAgentRun.runnerId ?? null, - jobName: result?.jobName ?? seedAgentRun.jobName ?? null, - namespace: result?.namespace ?? seedAgentRun.namespace ?? DEFAULT_RUNNER_NAMESPACE, - status: result?.status ?? command.state ?? seedAgentRun.status ?? null, - runStatus: result?.runStatus ?? seedAgentRun.runStatus ?? null, - commandState: result?.commandState ?? command.state ?? null, - terminalStatus: result?.terminalStatus ?? null, - eventStartSeq: afterSeq > 0 ? afterSeq + 1 : null, - lastSeq: endSeq || 0, - traceId, - sessionId: result?.sessionRef?.sessionId ?? command?.payload?.sessionId ?? seedAgentRun.sessionId ?? null, - conversationId: result?.sessionRef?.conversationId ?? command?.payload?.conversationId ?? seedAgentRun.conversationId ?? session?.conversationId ?? null, - threadId: result?.sessionRef?.threadId ?? command?.payload?.threadId ?? seedAgentRun.threadId ?? session?.threadId ?? null, - valuesPrinted: false - }; - const finalText = firstNonEmpty(result?.reply); - const finalResponse = finalText ? { - text: finalText, - textChars: finalText.length, - messageId: `msg_${traceId.slice(4)}`, - role: "assistant", - status: result?.status ?? result?.terminalStatus ?? "completed", - traceId, - createdAt: command.updatedAt ?? result?.updatedAt ?? now, - updatedAt: command.updatedAt ?? result?.updatedAt ?? now, - source: "agentrun-command-result-repair", - valuesPrinted: false - } : null; - const traceSummary = { - traceId, - source: "agent-session-agentrun-command-repair", - sourceEventCount: traceEvents.length, - terminalStatus: result?.terminalStatus ?? command.state ?? null, - agentRun: { runId: agentRun.runId, commandId: agentRun.commandId, lastSeq: agentRun.lastSeq, eventStartSeq: agentRun.eventStartSeq, valuesPrinted: false }, - updatedAt: now, - valuesPrinted: false - }; - const traceEvidence = { - traceId, - status: result?.status ?? result?.terminalStatus ?? command.state ?? "completed", - conversationId: agentRun.conversationId, - sessionId: safeSessionId(command?.payload?.hwlabSessionId) || safeSessionId(session?.id) || agentRun.sessionId, - threadId: agentRun.threadId, - messageId: `msg_${traceId.slice(4)}`, - createdAt: command.createdAt ?? session?.startedAt ?? now, - updatedAt: command.updatedAt ?? now, - finalResponse, - traceSummary, - agentRun, - valuesRedacted: true, - secretMaterialStored: false - }; - await persistTraceRepairEvidence({ session, traceId, traceEvidence, options }); - return persistedAgentRunResultFromSession({ traceId, session, agentRun, traceEvidence, options }); +function agentRunSeedFromSession(session, traceId) { + const snapshot = session?.session && typeof session.session === "object" ? session.session : null; + const traceResults = snapshot?.traceResults && typeof snapshot.traceResults === "object" ? snapshot.traceResults : null; + const traceAgentRun = traceResults?.[traceId]?.agentRun && typeof traceResults[traceId].agentRun === "object" ? traceResults[traceId].agentRun : null; + if (traceAgentRun?.runId) return traceAgentRun; + const topLevelAgentRun = snapshot?.agentRun && typeof snapshot.agentRun === "object" ? snapshot.agentRun : null; + return topLevelAgentRun?.runId ? topLevelAgentRun : null; +} + +async function findAgentRunCommandForTrace({ fetchImpl, managerUrl, timeoutMs, runId, traceId }) { + if (!runId || !safeTraceId(traceId)) return null; + const commands = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(runId)}/commands?afterSeq=0&limit=100`, { method: "GET", timeoutMs }); + return (Array.isArray(commands?.items) ? commands.items : []).find((item) => agentRunCommandMatchesTrace(item, traceId)) ?? null; } function agentRunCommandMatchesTrace(command, traceId) { @@ -751,71 +716,6 @@ function agentRunCommandMatchesTrace(command, traceId) { return command?.idempotencyKey === traceId || payload.traceId === traceId; } -async function persistTraceRepairEvidence({ session, traceId, traceEvidence, options = {} }) { - if (!session?.id || !session.ownerUserId || typeof options.accessController?.recordAgentSessionOwner !== "function") return; - await options.accessController.recordAgentSessionOwner({ - ownerUserId: session.ownerUserId, - sessionId: session.id, - projectId: session.projectId, - agentId: session.agentId, - status: session.status, - conversationId: session.conversationId, - threadId: session.threadId, - lastTraceId: session.lastTraceId, - session: { traceResults: { [traceId]: traceEvidence }, source: "agent-session-trace-repair", valuesRedacted: true, secretMaterialStored: false } - }); -} - -function agentSessionTraceEvidence(session, traceId) { - const id = safeTraceId(traceId); - const traceResults = session?.session?.traceResults && typeof session.session.traceResults === "object" ? session.session.traceResults : null; - const evidence = id && traceResults?.[id] && typeof traceResults[id] === "object" ? traceResults[id] : null; - return evidence ? { ...evidence, traceId: id } : null; -} - -function validAgentSessionTraceEvidence(session, traceId) { - const evidence = agentSessionTraceEvidence(session, traceId); - if (!evidence) return null; - return agentRunEvidenceMatchesTrace(evidence.agentRun, traceId, evidence, { requireAgentRunTraceId: true }) ? evidence : null; -} - -function agentSessionTopLevelTraceEvidence(session, traceId) { - const snapshot = session?.session && typeof session.session === "object" ? session.session : null; - if (!snapshot) return null; - const evidence = { - finalResponse: snapshot.finalResponse, - traceSummary: snapshot.traceSummary - }; - return agentRunEvidenceMatchesTrace(snapshot.agentRun, traceId, evidence) ? evidence : null; -} - -function ambiguousTopLevelAgentRunTrace(agentRun) { - const run = agentRun && typeof agentRun === "object" ? agentRun : null; - return Boolean(run?.runId && run?.commandId && !safeTraceId(run.traceId)); -} - -function agentRunEvidenceMatchesTrace(agentRun, traceId, traceEvidence = null, options = {}) { - const id = safeTraceId(traceId); - if (!id) return false; - const run = agentRun && typeof agentRun === "object" ? agentRun : null; - const runTraceId = safeTraceId(run?.traceId); - if (run?.commandId && runTraceId && runTraceId !== id) return false; - if (options.requireAgentRunTraceId === true && run?.commandId && runTraceId !== id) return false; - const providerTrace = run?.providerTrace && typeof run.providerTrace === "object" ? run.providerTrace : null; - const summary = traceEvidence?.traceSummary && typeof traceEvidence.traceSummary === "object" ? traceEvidence.traceSummary : null; - const summaryRun = summary?.agentRun && typeof summary.agentRun === "object" ? summary.agentRun : null; - const finalResponse = traceEvidence?.finalResponse && typeof traceEvidence.finalResponse === "object" ? traceEvidence.finalResponse : null; - const traceIds = [runTraceId, providerTrace?.traceId, summary?.traceId, finalResponse?.traceId] - .map((value) => safeTraceId(value)) - .filter(Boolean); - if (traceIds.some((value) => value !== id)) return false; - const commandIds = [run?.commandId, providerTrace?.commandId, summaryRun?.commandId] - .map((value) => text(value)) - .filter(Boolean); - if (commandIds.length > 1 && new Set(commandIds).size > 1) return false; - return true; -} - export function agentRunSessionEvidence(payload = {}) { if (!payload?.agentRun) return {}; return { @@ -1314,7 +1214,7 @@ function agentRunToolCalls(result = {}, status = "completed") { }]; } -function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) { +function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId, appendResultEvent = true }) { const terminalStatus = String(result?.terminalStatus ?? ""); const terminal = terminalStatus === "completed" || terminalStatus === "failed" || terminalStatus === "blocked" || terminalStatus === "cancelled"; if (!terminal) { @@ -1325,17 +1225,21 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) const terminalEventCreatedAt = agentRunResultTraceCreatedAt(runnerTrace, now); const providerTrace = agentRunProviderTrace({ base, result, terminalStatus }); if (terminalStatus === "completed" && String(result?.reply ?? "").trim()) { - traceStore.append(traceId, { - type: "result", - status: "completed", - label: "agentrun:result:completed", - createdAt: terminalEventCreatedAt, - message: "AgentRun result is ready for HWLAB short-connection polling.", - runId: base.agentRun.runId, - commandId: base.agentRun.commandId, - terminal: true, - valuesPrinted: false - }); + const finalResponse = agentRunCompletedFinalResponse({ base, result, traceId, now }); + const traceSummary = agentRunCompletedTraceSummary({ base, runnerTrace, finalResponse, traceId }); + if (appendResultEvent) { + traceStore.append(traceId, { + type: "result", + status: "completed", + label: "agentrun:result:completed", + createdAt: terminalEventCreatedAt, + message: "AgentRun result is ready for HWLAB short-connection polling.", + runId: base.agentRun.runId, + commandId: base.agentRun.commandId, + terminal: true, + valuesPrinted: false + }); + } return { ...base, status: "completed", @@ -1350,11 +1254,13 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) skills: { status: "delegated", provider: ADAPTER_ID, count: 0, items: [], valuesPrinted: false }, longLivedSessionGate: agentRunLongLivedSessionGate(base), providerTrace, + finalResponse, + traceSummary, reply: { - messageId: base.messageId ?? `msg_${traceId.slice(4)}`, + messageId: finalResponse.messageId, role: "assistant", - content: String(result.reply), - createdAt: now + content: finalResponse.text, + createdAt: finalResponse.createdAt }, usage: null, agentRun: { ...base.agentRun, terminalStatus, completed: true, reuseEligible: true, providerTrace, valuesPrinted: false }, @@ -1365,18 +1271,20 @@ 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 attribution = agentRunFailureAttribution({ code, message, canceled }); - traceStore.append(traceId, { - type: "result", - status: canceled ? "canceled" : "failed", - label: `agentrun:result:${canceled ? "canceled" : terminalStatus || "failed"}`, - createdAt: terminalEventCreatedAt, - errorCode: code, - message: attribution.summary, - runId: base.agentRun.runId, - commandId: base.agentRun.commandId, - terminal: true, - valuesPrinted: false - }); + if (appendResultEvent) { + traceStore.append(traceId, { + type: "result", + status: canceled ? "canceled" : "failed", + label: `agentrun:result:${canceled ? "canceled" : terminalStatus || "failed"}`, + createdAt: terminalEventCreatedAt, + errorCode: code, + message: attribution.summary, + runId: base.agentRun.runId, + commandId: base.agentRun.commandId, + terminal: true, + valuesPrinted: false + }); + } const partialContext = partialAgentRunContext(runnerTrace); return { ...base, @@ -1418,6 +1326,47 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) }; } +function agentRunCompletedFinalResponse({ base, result, traceId, now }) { + const textValue = String(result?.reply ?? "").trim(); + return { + text: textValue, + textChars: textValue.length, + role: "assistant", + status: "completed", + traceId, + messageId: base.messageId ?? base.reply?.messageId ?? `msg_${traceId.slice(4)}`, + createdAt: base.reply?.createdAt ?? base.createdAt ?? now, + updatedAt: now, + valuesPrinted: false + }; +} + +function agentRunCompletedTraceSummary({ base, runnerTrace, finalResponse, traceId }) { + const events = Array.isArray(runnerTrace?.events) ? runnerTrace.events : []; + const lastSeq = Math.max(0, ...events.map((event) => Number(event?.seq ?? 0)).filter(Number.isFinite)); + return { + traceId, + source: "agentrun-command-result", + sourceEventCount: Number(runnerTrace?.eventCount ?? events.length ?? 0), + terminalStatus: "completed", + finalAssistantRow: { + role: finalResponse.role, + status: finalResponse.status, + textChars: finalResponse.textChars, + textPreview: finalResponse.text.slice(0, 240), + messageId: finalResponse.messageId, + valuesPrinted: false + }, + agentRun: { + runId: base.agentRun?.runId ?? null, + commandId: base.agentRun?.commandId ?? null, + lastSeq, + valuesPrinted: false + }, + valuesPrinted: false + }; +} + function partialAgentRunContext(runnerTrace = {}) { const events = Array.isArray(runnerTrace.events) ? runnerTrace.events : []; const assistantMessages = events diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 5a7e1c0e..3aa8cb60 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -355,6 +355,13 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte } return send({ id: secondTurn ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", runId: "run_hwlab_adapter", state: "pending", type: "turn", seq: secondTurn ? 2 : 1 }); } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands") { + return send({ items: [ + { id: "cmd_hwlab_adapter", runId: "run_hwlab_adapter", state: "completed", type: "turn", seq: 1, idempotencyKey: "trc_server-test-agentrun-adapter", payload: { traceId: "trc_server-test-agentrun-adapter", conversationId: "cnv_server-test-agentrun", hwlabSessionId: "ses_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", providerProfile: "deepseek" } }, + { id: "cmd_hwlab_adapter_steer", runId: "run_hwlab_adapter", state: "completed", type: "steer", seq: 7, idempotencyKey: "trc_steer_server_test", payload: { traceId: "trc_steer_server_test", targetTraceId: "trc_server-test-agentrun-adapter", targetCommandId: "cmd_hwlab_adapter", conversationId: "cnv_server-test-agentrun", hwlabSessionId: "ses_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", providerProfile: "deepseek" } }, + { id: "cmd_hwlab_adapter_second", runId: "run_hwlab_adapter", state: "completed", type: "turn", seq: 2, idempotencyKey: "trc_server-test-agentrun-adapter-second", payload: { traceId: "trc_server-test-agentrun-adapter-second", conversationId: "cnv_server-test-agentrun", hwlabSessionId: "ses_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", providerProfile: "deepseek" } } + ] }); + } if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/runner-jobs") { const secondRunnerJob = body.commandId === "cmd_hwlab_adapter_second"; assert.ok(body.commandId === "cmd_hwlab_adapter" || secondRunnerJob); @@ -752,6 +759,11 @@ test("cloud api AgentRun adapter reports persistent thread resume when a complet assert.match(body.payload.prompt, /ISSUE812_RESUME/u); return send({ id: "cmd_issue812_resume", runId: "run_issue812_resume", state: "pending", type: "turn", seq: 1 }); } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_issue812_resume/commands") { + return send({ items: [ + { id: "cmd_issue812_resume", runId: "run_issue812_resume", state: "completed", type: "turn", seq: 1, idempotencyKey: "trc_server-test-issue812-resume", payload: { traceId: "trc_server-test-issue812-resume", conversationId, hwlabSessionId, threadId, providerProfile: "deepseek" } } + ] }); + } if (request.method === "POST" && url.pathname === "/api/v1/runs/run_issue812_resume/runner-jobs") { assert.equal(body.commandId, "cmd_issue812_resume"); return send({ @@ -890,6 +902,11 @@ test("cloud api AgentRun adapter exposes invalid tool-call attribution in result if (request.method === "POST" && url.pathname === "/api/v1/runs/run_invalid_tool/commands") { return send({ id: "cmd_invalid_tool", runId: "run_invalid_tool", state: "pending", type: "turn", seq: 1 }); } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_invalid_tool/commands") { + return send({ items: [ + { id: "cmd_invalid_tool", runId: "run_invalid_tool", state: "failed", type: "turn", seq: 1, idempotencyKey: "trc_server-test-agentrun-invalid-tool", payload: { traceId: "trc_server-test-agentrun-invalid-tool", conversationId: "cnv_invalid_tool", hwlabSessionId: "ses_server-test-invalid-tool", threadId: "thread_invalid_tool", providerProfile: "deepseek" } } + ] }); + } if (request.method === "POST" && url.pathname === "/api/v1/runs/run_invalid_tool/runner-jobs") { return send({ action: "create-kubernetes-job", @@ -1030,6 +1047,11 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba assert.match(body.payload.prompt, /MiniMax-M3/u); return send({ id: "cmd_hwlab_minimax_m3", runId: "run_hwlab_minimax_m3", state: "pending", type: "turn", seq: 1 }); } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_minimax_m3/commands") { + return send({ items: [ + { id: "cmd_hwlab_minimax_m3", runId: "run_hwlab_minimax_m3", state: "completed", type: "turn", seq: 1, idempotencyKey: "trc_server-test-agentrun-minimax-m3", payload: { traceId: "trc_server-test-agentrun-minimax-m3", conversationId: "cnv_server-test-minimax-m3", hwlabSessionId: "ses_server-test-minimax-m3", threadId: null, providerProfile: "minimax-m3" } } + ] }); + } if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_minimax_m3/runner-jobs") { assert.equal(body.commandId, "cmd_hwlab_minimax_m3"); return send({ @@ -1201,6 +1223,11 @@ test("cloud api AgentRun adapter scopes AgentRun sessions by backend profile", a assert.equal(body.payload.hwlabSessionId, hwlabSessionId); return send({ id: "cmd_hwlab_profile_switch_m3", runId: "run_hwlab_profile_switch_m3", state: "pending", type: "turn", seq: 1 }); } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_profile_switch_m3/commands") { + return send({ items: [ + { id: "cmd_hwlab_profile_switch_m3", runId: "run_hwlab_profile_switch_m3", state: "completed", type: "turn", seq: 1, idempotencyKey: "trc_server-test-agentrun-profile-switch", payload: { traceId: "trc_server-test-agentrun-profile-switch", conversationId: "cnv_server-test-profile-switch", hwlabSessionId, threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", providerProfile: "minimax-m3" } } + ] }); + } if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_profile_switch_m3/runner-jobs") { assert.equal(body.commandId, "cmd_hwlab_profile_switch_m3"); return send({ @@ -1574,8 +1601,10 @@ test("cloud api /v1/agent/chat/inspect returns not_found for empty conversation } }); -test("cloud api trace returns persisted summary when live trace store has expired", async () => { +test("cloud api trace uses AgentRun command result evidence when live trace store has expired", async () => { const ownerSessions = new Map(); + const agentRunCalls = []; + const commandFinalText = "历史 trace 已过期,但 final response 来自 AgentRun command result。"; ownerSessions.set("ses_issue842_expired", testAgentSessionRecord({ sessionId: "ses_issue842_expired", projectId: "prj_v02_code_agent", @@ -1585,8 +1614,8 @@ test("cloud api trace returns persisted summary when live trace store has expire status: "active", session: { finalResponse: { - text: "历史 trace 已过期,但 final response 仍可审计。", - textChars: 31, + text: "旧 session snapshot final 不应作为历史 trace 权威。", + textChars: 30, role: "assistant", status: "completed", traceId: "trc_issue842_expired", @@ -1595,7 +1624,7 @@ test("cloud api trace returns persisted summary when live trace store has expire }, traceSummary: { traceId: "trc_issue842_expired", - source: "agent-session-snapshot", + source: "stale-agent-session-snapshot", sourceEventCount: 26, terminalStatus: "completed", noiseEventCount: 4, @@ -1615,12 +1644,52 @@ test("cloud api trace returns persisted summary when live trace store has expire valuesRedacted: true } })); + const agentRunServer = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + agentRunCalls.push({ method: request.method, path: url.pathname, search: url.search }); + const send = (data) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_issue842_expired" })}\n`); + }; + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_issue842_expired/commands") { + return send({ items: [ + { id: "cmd_issue842_expired", runId: "run_issue842_expired", state: "completed", type: "turn", seq: 1, idempotencyKey: "trc_issue842_expired", payload: { traceId: "trc_issue842_expired", conversationId: "cnv_issue842_expired", hwlabSessionId: "ses_issue842_expired", threadId: "thread-issue-842-expired", providerProfile: "deepseek" } } + ] }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_issue842_expired/events") { + return send({ items: [] }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_issue842_expired/commands/cmd_issue842_expired/result") { + return send({ + runId: "run_issue842_expired", + commandId: "cmd_issue842_expired", + attemptId: "attempt_issue842_expired", + runnerId: "runner_issue842_expired", + jobName: "agentrun-v01-runner-issue842-expired", + namespace: "agentrun-v01", + status: "completed", + runStatus: "completed", + commandState: "completed", + terminalStatus: "completed", + completed: true, + reply: commandFinalText, + lastSeq: 26, + eventCount: 26, + sessionRef: { sessionId: "ses_agentrun_issue842_expired", conversationId: "cnv_issue842_expired", threadId: "thread-issue-842-expired" } + }); + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + + const agentRunPort = agentRunServer.address().port; const traceStore = createCodeAgentTraceStore(); const server = createCloudApiServer({ traceStore, env: { HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", - AGENTRUN_MGR_URL: "http://127.0.0.1:9", + AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", HWLAB_ENVIRONMENT: "v02", HWLAB_GITOPS_PROFILE: "v02" @@ -1649,16 +1718,24 @@ test("cloud api trace returns persisted summary when live trace store has expire assert.equal(body.traceStatus, "expired"); assert.equal(body.eventCount, 0); assert.equal(body.fallback.available, true); - assert.equal(body.finalResponse.text, "历史 trace 已过期,但 final response 仍可审计。"); - assert.equal(body.traceSummary.sourceEventCount, 26); + assert.equal(body.fallback.source, "agentrun-command-result"); + assert.equal(body.finalResponse.text, commandFinalText); + assert.equal(body.traceSummary.source, "agentrun-command-result"); + assert.equal(body.traceSummary.sourceEventCount, 0); assert.equal(body.agentRun.runId, "run_issue842_expired"); assert.equal(body.agentRun.commandId, "cmd_issue842_expired"); assert.equal(body.retention.liveTraceStore, "expired-or-evicted"); + assert.equal(JSON.stringify(body).includes("旧 session snapshot final"), false); + assert.ok(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_issue842_expired/commands")); + assert.ok(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_issue842_expired/commands/cmd_issue842_expired/result")); assert.equal(JSON.stringify(body).includes("password"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); + await new Promise((resolve, reject) => { + agentRunServer.close((error) => (error ? reject(error) : resolve())); + }); } }); @@ -1684,10 +1761,21 @@ test("cloud api trace replays an earlier AgentRun command after same-run lastSeq const agentRunServer = createHttpServer(async (request, response) => { const url = new URL(request.url || "/", "http://127.0.0.1"); calls.push({ method: request.method, path: url.pathname, afterSeq: url.searchParams.get("afterSeq") }); - if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) { + const send = (data) => { response.writeHead(200, { "content-type": "application/json" }); - response.end(`${JSON.stringify({ ok: true, data: { items: [...firstEvents, ...secondEvents] }, traceId: "trc_fake_issue955_replay" })}\n`); - return; + response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_issue955_replay" })}\n`); + }; + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands`) { + return send({ items: [ + { id: firstCommandId, runId, state: "completed", idempotencyKey: firstTraceId, createdAt: "2026-06-06T01:43:30.040Z", updatedAt: "2026-06-06T01:43:56.000Z", payload: { traceId: firstTraceId, conversationId: "cnv_issue955_replay", hwlabSessionId: "ses_issue955_replay", threadId: "thread-issue955-replay", providerProfile: "deepseek" } }, + { id: secondCommandId, runId, state: "completed", idempotencyKey: "trc_issue955_second_trace", createdAt: "2026-06-06T01:46:11.000Z", updatedAt: "2026-06-06T01:46:28.000Z", payload: { traceId: "trc_issue955_second_trace", conversationId: "cnv_issue955_replay", hwlabSessionId: "ses_issue955_replay", threadId: "thread-issue955-replay", providerProfile: "deepseek" } } + ] }); + } + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${firstCommandId}/result`) { + return send({ runId, commandId: firstCommandId, status: "completed", runStatus: "completed", commandState: "completed", terminalStatus: "completed", completed: true, reply: firstFinalText, lastSeq: 35, eventCount: 35, sessionRef: { sessionId: "ses_agentrun_deepseek_issue955_replay", conversationId: "cnv_issue955_replay", threadId: "thread-issue955-replay" } }); + } + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) { + return send({ items: [...firstEvents, ...secondEvents] }); } response.writeHead(404, { "content-type": "application/json" }); response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`); @@ -1760,7 +1848,6 @@ test("cloud api trace replays an earlier AgentRun command after same-run lastSeq test("cloud api repairs historical same-session AgentRun trace after lastTraceId advances (#955)", async () => { const calls = []; - const persistedOwnerInputs = []; const ownerSessions = new Map(); const runId = "run_issue955_historical"; const firstTraceId = "trc_issue955_historical_first"; @@ -1857,7 +1944,6 @@ test("cloud api repairs historical same-session AgentRun trace after lastTraceId return [...ownerSessions.values()].find((session) => session.lastTraceId === traceId || session.session?.messages?.some((message) => message.traceId === traceId)) ?? null; }, async recordAgentSessionOwner(input) { - persistedOwnerInputs.push(input); const existing = ownerSessions.get(input.sessionId) ?? testAgentSessionRecord(input); const nextSession = { ...(existing.session ?? {}), ...(input.session ?? {}) }; nextSession.traceResults = { ...(existing.session?.traceResults ?? {}), ...(input.session?.traceResults ?? {}) }; @@ -1889,9 +1975,6 @@ test("cloud api repairs historical same-session AgentRun trace after lastTraceId assert.ok(calls.some((call) => call.path === `/api/v1/runs/${runId}/commands`)); assert.ok(calls.some((call) => call.path === `/api/v1/runs/${runId}/commands/${firstCommandId}/result`)); assert.equal(calls.some((call) => call.path === `/api/v1/runs/${runId}/commands/${secondCommandId}/result`), false); - const repair = persistedOwnerInputs.find((input) => input.session?.traceResults?.[firstTraceId]); - assert.ok(repair); - assert.equal(repair.session.traceResults[firstTraceId].agentRun.commandId, firstCommandId); assert.equal(ownerSessions.get("ses_issue955_historical").session.traceResults[firstTraceId].finalResponse.text, firstFinalText); const repairedSession = ownerSessions.get("ses_issue955_historical"); assert.equal(repairedSession.lastTraceId, secondTraceId); @@ -1923,6 +2006,207 @@ test("cloud api repairs historical same-session AgentRun trace after lastTraceId } }); +test("cloud api result polling repairs polluted completed AgentRun memory cache (#955)", async () => { + const calls = []; + const runId = "run_issue955_live_cache"; + const firstTraceId = "trc_issue955_live_cache_first"; + const secondTraceId = "trc_issue955_live_cache_second"; + const firstCommandId = "cmd_issue955_live_cache_first"; + const secondCommandId = "cmd_issue955_live_cache_second"; + const firstFinalText = "目前只有一个 HWPOD 可用:d601-f103-v2。"; + const secondFinalText = "编译成功!hwpod build completed,Keil MDK USART atk_f103.hex queued。"; + const events = [ + { id: "evt_issue955_live_first_message", runId, seq: 10, type: "assistant_message", payload: { commandId: firstCommandId, itemId: "msg_issue955_live_first", text: firstFinalText }, createdAt: "2026-06-06T01:46:19.000Z" }, + { id: "evt_issue955_live_first_done", runId, seq: 11, type: "terminal_status", payload: { commandId: firstCommandId, terminalStatus: "completed" }, createdAt: "2026-06-06T01:46:19.000Z" }, + { id: "evt_issue955_live_second_message", runId, seq: 20, type: "assistant_message", payload: { commandId: secondCommandId, itemId: "msg_issue955_live_second", text: secondFinalText }, createdAt: "2026-06-06T01:46:28.000Z" }, + { id: "evt_issue955_live_second_done", runId, seq: 21, type: "terminal_status", payload: { commandId: secondCommandId, terminalStatus: "completed" }, createdAt: "2026-06-06T01:46:28.000Z" } + ]; + const agentRunServer = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + calls.push({ method: request.method, path: url.pathname, search: url.search }); + const send = (data) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_issue955_live_cache" })}\n`); + }; + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands`) { + return send({ items: [ + { id: firstCommandId, runId, state: "completed", idempotencyKey: firstTraceId, payload: { traceId: firstTraceId, conversationId: "cnv_issue955_live_cache", hwlabSessionId: "ses_issue955_live_cache", threadId: "thread-issue955-live-cache" } }, + { id: secondCommandId, runId, state: "completed", idempotencyKey: secondTraceId, payload: { traceId: secondTraceId, conversationId: "cnv_issue955_live_cache", hwlabSessionId: "ses_issue955_live_cache", threadId: "thread-issue955-live-cache" } } + ] }); + } + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${firstCommandId}/result`) { + return send({ runId, commandId: firstCommandId, status: "completed", runStatus: "completed", commandState: "completed", terminalStatus: "completed", completed: true, reply: firstFinalText, lastSeq: 11, eventCount: 11 }); + } + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${secondCommandId}/result`) { + return send({ runId, commandId: secondCommandId, status: "completed", runStatus: "completed", commandState: "completed", terminalStatus: "completed", completed: true, reply: secondFinalText, lastSeq: 21, eventCount: 21 }); + } + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) { + return send({ items: events }); + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + + const agentRunPort = agentRunServer.address().port; + const codeAgentChatResults = new Map(); + codeAgentChatResults.set(secondTraceId, { + accepted: true, + status: "completed", + shortConnection: true, + traceId: secondTraceId, + conversationId: "cnv_issue955_live_cache", + sessionId: "ses_issue955_live_cache", + threadId: "thread-issue955-live-cache", + ownerUserId: TEST_AGENT_ACTOR.id, + ownerRole: TEST_AGENT_ACTOR.role, + reply: { role: "assistant", content: firstFinalText }, + finalResponse: { text: firstFinalText, textChars: firstFinalText.length, role: "assistant", status: "completed", traceId: secondTraceId, valuesPrinted: false }, + traceSummary: { traceId: secondTraceId, source: "agent-session-snapshot", sourceEventCount: 11, terminalStatus: "completed", agentRun: { runId, commandId: firstCommandId, lastSeq: 11, valuesPrinted: false }, valuesPrinted: false }, + agentRun: { + adapter: "agentrun-v01", + managerUrl: `http://127.0.0.1:${agentRunPort}`, + runId, + commandId: firstCommandId, + traceId: secondTraceId, + lastSeq: 11, + terminalStatus: "completed", + providerTrace: { traceId: secondTraceId, runId, commandId: firstCommandId, terminalStatus: "completed", valuesPrinted: false }, + valuesPrinted: false + }, + providerTrace: { traceId: secondTraceId, runId, commandId: firstCommandId, terminalStatus: "completed", valuesPrinted: false }, + valuesPrinted: false + }); + + const server = createCloudApiServer({ + traceStore: createCodeAgentTraceStore(), + codeAgentChatResults, + env: { + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_ENVIRONMENT: "v02", + HWLAB_GITOPS_PROFILE: "v02" + }, + accessController: { + required: false, + async authenticate() { + return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/result/${secondTraceId}`, { + headers: { cookie: "hwlab_session=test-stub-session" } + }); + assert.equal(response.status, 200); + const body = await response.json(); + const text = JSON.stringify(body); + assert.equal(body.traceId, secondTraceId); + assert.equal(body.status, "completed"); + assert.equal(body.agentRun.commandId, secondCommandId); + assert.equal(body.agentRun.traceId, secondTraceId); + assert.equal(body.reply.content, secondFinalText); + assert.match(text, /编译成功/u); + assert.match(text, /Keil MDK/u); + assert.doesNotMatch(text, /目前只有一个 HWPOD 可用/u); + assert.ok(calls.some((call) => call.path === `/api/v1/runs/${runId}/commands`)); + assert.ok(calls.some((call) => call.path === `/api/v1/runs/${runId}/commands/${secondCommandId}/result`)); + assert.equal(calls.some((call) => call.path === `/api/v1/runs/${runId}/commands/${firstCommandId}/result`), false); + assert.equal(codeAgentChatResults.get(secondTraceId).agentRun.commandId, secondCommandId); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await new Promise((resolve, reject) => { + agentRunServer.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test("cloud api result polling fails closed when AgentRun command registry misses trace (#955)", async () => { + const calls = []; + const runId = "run_issue955_registry_missing"; + const traceId = "trc_issue955_registry_missing"; + const staleCommandId = "cmd_issue955_registry_stale"; + const staleFinalText = "目前只有一个 HWPOD 可用:d601-f103-v2。"; + const agentRunServer = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + calls.push({ method: request.method, path: url.pathname }); + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands`) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: true, data: { items: [] }, traceId: "trc_fake_issue955_missing" })}\n`); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + + const agentRunPort = agentRunServer.address().port; + const codeAgentChatResults = new Map(); + codeAgentChatResults.set(traceId, { + accepted: true, + status: "completed", + shortConnection: true, + traceId, + conversationId: "cnv_issue955_registry_missing", + sessionId: "ses_issue955_registry_missing", + threadId: "thread-issue955-registry-missing", + ownerUserId: TEST_AGENT_ACTOR.id, + ownerRole: TEST_AGENT_ACTOR.role, + reply: { role: "assistant", content: staleFinalText }, + finalResponse: { text: staleFinalText, textChars: staleFinalText.length, role: "assistant", status: "completed", traceId, valuesPrinted: false }, + agentRun: { adapter: "agentrun-v01", managerUrl: `http://127.0.0.1:${agentRunPort}`, runId, commandId: staleCommandId, traceId, terminalStatus: "completed", valuesPrinted: false }, + valuesPrinted: false + }); + + const server = createCloudApiServer({ + traceStore: createCodeAgentTraceStore(), + codeAgentChatResults, + env: { + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_ENVIRONMENT: "v02", + HWLAB_GITOPS_PROFILE: "v02" + }, + accessController: { + required: false, + async authenticate() { + return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/result/${traceId}`, { + headers: { cookie: "hwlab_session=test-stub-session" } + }); + assert.equal(response.status, 404); + const body = await response.json(); + const text = JSON.stringify(body); + assert.equal(body.error.code, "agentrun_trace_command_not_found"); + assert.equal(body.error.traceId, traceId); + assert.equal(body.error.runId, runId); + assert.doesNotMatch(text, /目前只有一个 HWPOD 可用/u); + assert.ok(calls.some((call) => call.path === `/api/v1/runs/${runId}/commands`)); + assert.equal(calls.some((call) => call.path.includes("/result")), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await new Promise((resolve, reject) => { + agentRunServer.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api result polling compacts large runnerTrace while preserving providerTrace", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-codex-home-")); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index b711c09d..9f02819a 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -828,7 +828,8 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti }); return; } - if ((result?.agentRun?.runId && result.status === "running") || (!result && codeAgentAgentRunAdapterEnabled(options.env ?? process.env))) { + const adapterEnabled = codeAgentAgentRunAdapterEnabled(options.env ?? process.env); + if ((result?.agentRun?.runId && adapterEnabled) || (!result && adapterEnabled)) { try { const synced = await syncAgentRunChatResult({ traceId, currentResult: result, options, traceStore: options.traceStore ?? defaultCodeAgentTraceStore }); if (synced.result && !canAccessOwnedResult(synced.result, options.actor)) { @@ -868,7 +869,7 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti message: error?.message ?? "AgentRun result polling failed", waitingFor: "agentrun-result" }); - if (result?.agentRun) { + if (result?.agentRun && result.status === "running") { sendJson(response, 202, { accepted: true, status: "running", @@ -882,6 +883,18 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti }); return; } + if (result?.agentRun) { + sendJson(response, error?.statusCode === 404 ? 404 : 502, { + error: { + code: error?.code ?? "agentrun_result_poll_failed", + message: error?.message ?? "AgentRun result polling failed", + traceId, + runId: result.agentRun.runId ?? null, + commandId: result.agentRun.commandId ?? null + } + }); + return; + } } } if (result && result.status !== "running") { @@ -1360,7 +1373,7 @@ export async function handleCodeAgentSteerHttp(request, response, options) { } } -async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options = {}, status = "active" } = {}) { +async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options = {}, status = "active", preserveLastTraceId = false } = {}) { const ownerUserId = options.actor?.id ?? params.ownerUserId; if (!ownerUserId || !options.accessController?.recordAgentSessionOwner) return null; const traceId = safeTraceId(payload.traceId ?? params.traceId); @@ -1377,7 +1390,7 @@ async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options projectId: params.projectId ?? payload.projectId ?? null, conversationId, threadId, - traceId, + traceId: preserveLastTraceId ? undefined : traceId, status, session: codeAgentSessionOwnerEvidence(payload, params) }); @@ -1491,12 +1504,12 @@ function isWorkbenchOrphanActiveTrace(snapshot) { async function resolveWorkbenchActiveTraceResult(traceId, options = {}) { if (!safeTraceId(traceId)) return null; const cached = options.codeAgentChatResults?.get?.(traceId) ?? null; - if (cached?.agentRun?.runId && cached.status === "running") { + if (cached?.agentRun?.runId) { try { const synced = await syncAgentRunChatResult({ traceId, currentResult: cached, options, traceStore: options.traceStore ?? defaultCodeAgentTraceStore }); - return synced.result ?? cached; + return synced.result ?? null; } catch { - return cached; + return null; } } if (cached) return cached; @@ -1505,7 +1518,7 @@ async function resolveWorkbenchActiveTraceResult(traceId, options = {}) { const persisted = await loadPersistedAgentRunResult(traceId, options); if (!persisted?.agentRun?.runId) return persisted; const synced = await syncAgentRunChatResult({ traceId, currentResult: persisted, options, traceStore: options.traceStore ?? defaultCodeAgentTraceStore }); - return synced.result ?? persisted; + return synced.result ?? null; } catch { return null; } @@ -1835,7 +1848,7 @@ export async function handleCodeAgentTraceHttp(request, response, url, options) return; } - const agentRunResult = result?.agentRun ? result : await loadPersistedAgentRunResult(traceId, options); + let agentRunResult = result?.agentRun ? result : await loadPersistedAgentRunResult(traceId, options); let refreshError = null; if (agentRunResult && !canAccessOwnedResult(agentRunResult, options.actor)) { sendJson(response, 403, { @@ -1848,6 +1861,12 @@ export async function handleCodeAgentTraceHttp(request, response, url, options) } if (agentRunResult?.agentRun) { try { + const synced = await syncAgentRunChatResult({ traceId, currentResult: agentRunResult, options, traceStore, appendResultEvent: false }); + agentRunResult = synced.result ?? agentRunResult; + if (agentRunResult?.status && agentRunResult.status !== "running") { + recordCodeAgentConversationFact(agentRunResult, options); + await recordCodeAgentSessionOwner({ payload: agentRunResult, params: agentRunResult, options, status: codeAgentOwnerStatusForResult(agentRunResult), preserveLastTraceId: true }); + } await refreshAgentRunTrace({ traceId, result: agentRunResult, options, traceStore }); } catch (error) { refreshError = error;