diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index 63542ad5..956e3c8a 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -158,9 +158,9 @@ Forwarder 不需要人工维护长驻进程。正式固化后,它应由 Kubern 只有“真实 DEV 路由 + `completed` + 非空 assistant reply”能作为 DEV-LIVE 回复通过依据。 不得把 mock、fixture、本地 echo、source report、静态检查或前端状态当作通过。 -AgentRun 接入后,`agentrun-v01` 只能表示执行基础设施,不等同于真实 provider/model。Code Agent result `completed` 要被 Web 或 CLI 标记为真实完成,必须同时具备 final assistant response、真实 provider/model、traceId、conversationId/sessionId 和可回放 trace;否则应显示“完成证据不足”,不能用 SOURCE、fixture、echo、mock 或 stub 补齐。 +AgentRun 接入后,`agentrun-v01` 只能表示执行基础设施,不等同于真实 provider/model。Code Agent result `completed` 要被 Web 或 CLI 标记为真实完成,必须同时具备 final assistant response、真实 provider/model、`providerTrace`、traceId、conversationId/sessionId 和可回放 trace;否则应显示“完成证据不足”,不能用 SOURCE、fixture、echo、mock 或 stub 补齐。AgentRun adapter 应把真实执行形态暴露为 `codex-stdio` / Codex app-server stdio 等价长会话,把 `agentrun-v01` 保留在 adapter、runner 或 infrastructure 字段里。 -同一 conversation/session 的后续消息应优先进入既有 runner/session 的新 turn,避免每次消息都重新 bundle、重新启动 runner、再把历史 thread 当作新 runner 可 resume 的 rollout。必须新建 runner 时,trace/result 要说明原因,便于区分 runner 生命周期缺口和业务回答失败。 +同一 conversation/session 的后续消息必须在 AgentRun runner lease 有效时进入既有 run/runner 的新 command/turn,避免每次消息都重新 bundle、重新启动 runner、再把历史 thread 当作新 runner 可 resume 的 rollout。每条消息都重新 bundle 或启动新 runner 是功能缺口,不得只靠 trace 说明包装成通过。只有 run 已 terminal、runner lease 过期或 AgentRun manager 明确拒绝复用时才允许新建 runner;trace/result 可以说明原因,但该说明只是诊断证据,不替代复用能力本身。 Trace 展示问题按“同源 row 转换优先”排查:先用 `hwlab-cli client agent trace --render web` 输出 Cloud Web 同一 trace row 摘要;CLI 也乱说明 row 转换逻辑问题,CLI 正常而浏览器乱再查 DOM、CSS、滚动和增量 patch。 diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index eb0545e2..883b6d92 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -26,7 +26,7 @@ - `client agent send` 是 Cloud Web Code Agent composer 的非视觉等价入口。它必须支持 `--from-trace`、`--conversation-id`、`--session-id`、`--thread-id` 和 `--retry-of`,并在输出中返回 redacted continuation 摘要,证明本次 CLI 请求是否覆盖 Web 的继续会话路径。浏览器 issue 中已经给出 traceId 时,复现命令优先使用 `--from-trace `,让 CLI 先走 `/v1/agent/chat/inspect` 读取 Web 上下文,再提交同源 `/v1/agent/chat`。 - `client agent send` 默认先恢复账号 workspace,再向 `/v1/agent/chat` 发送 `workspaceId` 和 `expectedWorkspaceRevision`;服务端接受后 CLI 保存新的 workspace revision,终态轮询后再 PATCH workspace 清理终态 `activeTraceId`。只有显式 `--no-workspace` 才跳过这一默认恢复路径。 - `client agent trace --render web` 必须调用 Cloud Web trace row 的同一纯转换路径,输出 `render="web"`、renderer 标识、source event count、rendered row count 和 row 摘要。浏览器 trace 展示错乱时,必须先用该 CLI 入口确认 Web 渲染转换是否已经乱序、重复、缺 final response 或吞掉关键 row,再继续修浏览器 DOM/CSS。 -- AgentRun v0.1 短连接 runner 不保证历史 Web/Codex `threadId` 可在新 runner Job 内 resume;CLI 仍应把 Web 提交的 `threadId` 原样送到 Cloud Web API,以便验证 adapter 是否正确把它降级为 `requestedThreadId` 元数据,而不是让 runner 执行旧 thread resume。 +- 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 中的原因说明只能用于定位。 - `client harness`、`client harness-ops` 和 `client harness-opt` 吸收 G14 harness-ops 的短连接业务能力:health、submit、result、trace、wait 和 audit。 - harness 系列命令只调用 Cloud Web/Code Agent 同源 API,不创建镜像、Job、常驻服务,也不执行 hot-sync、kubectl cp 或硬编码 namespace/pod 的运行面写路径。 - 旧 `hwlab-cli cicd`、fixture MVP gate 和 CLI 镜像/Job 口径属于废弃路径;开发中遇到这些旧门禁、旧测试或旧预检时直接删除,不再维护兼容。 diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index 7d48a775..b3f9e38a 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -10,8 +10,8 @@ - Cloud Web 与 `hwlab-cli client` 必须共享同一组非视觉业务 API。浏览器遇到的 Code Agent continuation、trace/result、device-pod list/status 和 device-pod job 问题,必须能通过 `hwlab-cli client` 走同一 `19666` Cloud Web path 复现;不能让 CLI 长期绕到 `19667` Cloud API 后把 Web 路径缺口误判为业务已通过。 - 浏览器启动后必须从 `GET /v1/workbench/workspace` hydrate 账号 workspace;同一个账号在多个浏览器标签页、多个浏览器或 CLI profile 中应看到同一个 `workspaceId`、selected conversation/session/thread、provider profile 和 active trace。浏览器 localStorage 只能作为短期缓存,并必须绑定 actor,不能作为 workspace authority。 - Cloud Web trace 展示与 `hwlab-cli client agent trace --render web` 必须共享同一套 trace row 纯转换路径。Web 发生 row 顺序错乱、final response 缺失、assistant 消息被吞或噪声事件淹没时,先用 CLI 输出同一渲染 row 摘要复现;CLI 可复现说明是 trace row 转换问题,CLI 不可复现再进入 DOM/CSS/滚动状态调查。 -- Code Agent result `completed` 只有在同时包含真实 provider/model/trace/conversation 元数据和可展示的 final assistant response 时,才能被 Web 标记为真实完成;`provider=agentrun-v01` 只是执行基础设施标识,不得替代上游 provider/model,也不得把 SOURCE、fixture、echo、mock 或 stub 当成 DEV-LIVE 完成。 -- 同一 conversation/session 的后续用户消息应优先复用已存在的 AgentRun runner/session 继续 turn;只有 runner 不可用、已过期或协议明确要求新 runner 时才重新 bundle 和启动 runner。每条消息都重新 bundle/runner 属于 v0.2 AgentRun 接入缺口,必须在 trace 中显式可见。 +- 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 显示原因当成已完成。 ## 内部架构 @@ -57,7 +57,7 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验, ## T2.2 -阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:在同一 conversation/session 连续发送两条 Code Agent 消息,确认第二条不重新 materialize bundle/启动新 runner,或在 trace 中明确输出不能复用 runner 的原因;result completed 必须包含真实 provider/model/trace/conversation 和 final assistant response。 +阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:在同一 conversation/session 连续发送两条 Code Agent 消息,确认第二条复用第一条的 AgentRun `runId` 和 runner `jobName`、生成新的 `commandId`,且不重新 materialize bundle/启动新 runner;result completed 必须包含真实 provider/model/`providerTrace`/trace/conversation 和 final assistant response。复用失败原因只能作为诊断,不作为本测试通过条件。 ## T3 diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 617ac0ca..a27bc2dc 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -587,6 +587,13 @@ class AccessController { return this.store.getAgentSessionByTraceId?.(id) ?? null; } + async getAgentSession(sessionId) { + await this.ensureBootstrap(); + const id = safeAgentSessionId(sessionId); + if (!id) return null; + return this.store.getAgentSession?.(id) ?? null; + } + async listAgentConversations(request, response, url) { await this.ensureBootstrap(); const auth = await this.authenticate(request, { required: true }); diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 4cfc68a7..74ce9d38 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -1,6 +1,17 @@ import { randomUUID } from "node:crypto"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; +import { + CODEX_APP_SERVER_PROTOCOL, + CODEX_APP_SERVER_WIRE_API, + CODEX_STDIO_BACKEND, + CODEX_STDIO_CAPABILITY_LEVEL, + CODEX_STDIO_IMPLEMENTATION_TYPE, + CODEX_STDIO_PROVIDER, + CODEX_STDIO_RUNNER_KIND, + CODEX_STDIO_SESSION_MODE, + longLivedSessionGate +} from "./codex-stdio-session.ts"; import { parsePositiveInteger, safeConversationId, @@ -19,8 +30,9 @@ const DEFAULT_REPO_URL = "http://git-mirror-http.devops-infra.svc.cluster.local/ const DEFAULT_RUNNER_NAMESPACE = "agentrun-v01"; const DEFAULT_TIMEOUT_MS = 1_200_000; const AGENTRUN_BACKENDS = Object.freeze(["codex", "deepseek"]); -const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-new-runner-thread"; -const SESSION_POLICY_RUN_LOCAL = "hwlab-agentrun-v01-run-local-session"; +const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-reuse-runner-thread"; +const SESSION_POLICY_RUN_LOCAL = "hwlab-agentrun-v01-session-runner-reuse"; +const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "blocked", "cancelled", "canceled"]); export function codeAgentAgentRunAdapterEnabled(env = process.env) { const value = String(env.HWLAB_CODE_AGENT_ADAPTER ?? env.HWLAB_CODE_AGENT_PROVIDER ?? "").trim().toLowerCase(); @@ -42,12 +54,13 @@ export function initialAgentRunChatResult({ params = {}, options = {}, traceId } messageId: `msg_${safeTraceId(traceId)?.slice(4) || randomUUID()}`, createdAt: timestamp, updatedAt: timestamp, - provider: ADAPTER_ID, + provider: CODEX_STDIO_PROVIDER, model: modelForBackendProfile(backendProfile, env), - backend: `agentrun-v01/${backendProfile}`, - capabilityLevel: "agentrun-v01-manual-dispatch", - sessionMode: "agentrun-v01-session-ref", - implementationType: "hwlab-cloud-api-agentrun-v01-adapter", + backend: CODEX_STDIO_BACKEND, + infrastructureBackend: `agentrun-v01/${backendProfile}`, + 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: { @@ -76,14 +89,67 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI type: "request", status: "accepted", label: "agentrun:request:accepted", - message: "HWLAB Code Agent request accepted by the AgentRun v0.1 adapter; hwlab-cloud-api will manually create run/command/runner-job over the k3s Service DNS.", - waitingFor: "agentrun-run-create", + message: "HWLAB Code Agent request accepted by the AgentRun v0.1 adapter; hwlab-cloud-api will reuse an active runner when the HWLAB session has a live AgentRun lease, otherwise it will create run/command/runner-job over the k3s Service DNS.", + waitingFor: "agentrun-run-reuse-or-create", adapter: ADAPTER_ID, managerHost: new URL(managerUrl).hostname, valuesPrinted: false }, agentRunTraceMeta(env, params)); - const runInput = buildAgentRunCreateRunInput({ params, env, traceId, backendProfile }); + const reusable = await resolveReusableAgentRun({ params, options, env, managerUrl, fetchImpl, timeoutMs, backendProfile, traceId, traceStore }); + if (reusable?.mapping) { + try { + const commandInput = buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId: reusable.sessionId }); + const command = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(reusable.mapping.runId)}/commands`, { + method: "POST", + body: commandInput, + timeoutMs + }); + const commandId = requiredString(command?.id, "command.id"); + const mapping = agentRunReusedMapping({ previous: reusable.mapping, run: reusable.run, command, traceId, startedAt, backendProfile, managerUrl, env }); + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:command:created", + message: `AgentRun command ${commandId} created on reused run ${mapping.runId}; hwlab-cloud-api will not start a new runner Job for this turn.`, + runId: mapping.runId, + commandId, + backendProfile, + waitingFor: "agentrun-result", + valuesPrinted: false + }); + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:runner-job:reused", + message: `AgentRun runner Job ${mapping.jobName ?? "unknown"} is reused for this HWLAB session turn; no new bundle materialization is requested.`, + runId: mapping.runId, + commandId: mapping.commandId, + attemptId: mapping.attemptId, + runnerId: mapping.runnerId, + jobName: mapping.jobName, + namespace: mapping.namespace, + waitingFor: "agentrun-result", + valuesPrinted: false + }); + return decorateAgentRunRunningResult({ base: initialAgentRunChatResult({ params, options, traceId }), mapping, traceStore, traceId }); + } catch (error) { + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:run:reuse-command-failed", + message: `AgentRun reused run ${reusable.mapping.runId} rejected the new command; hwlab-cloud-api will create a fresh run/runner Job for this turn.`, + errorCode: error?.code ?? "agentrun_reuse_command_failed", + runId: reusable.mapping.runId, + commandId: reusable.mapping.commandId ?? null, + waitingFor: "agentrun-run-create", + valuesPrinted: false + }); + } + } + + const sessionId = agentRunSessionIdForParams(params, traceId); + const runInput = buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId }); const run = await agentRunJson(fetchImpl, managerUrl, "/api/v1/runs", { method: "POST", body: runInput, @@ -101,7 +167,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI valuesPrinted: false }); - const commandInput = buildAgentRunCommandInput({ params, traceId, backendProfile }); + const commandInput = buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }); const command = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(runId)}/commands`, { method: "POST", body: commandInput, @@ -263,12 +329,13 @@ export async function loadPersistedAgentRunResult(traceId, options = {}) { createdAt: session.startedAt ?? session.updatedAt ?? nowIso(options.now), updatedAt: session.updatedAt ?? nowIso(options.now), ownerUserId: session.ownerUserId, - provider: ADAPTER_ID, + provider: CODEX_STDIO_PROVIDER, model: modelForBackendProfile(agentRun.backendProfile, options.env ?? process.env), - backend: `agentrun-v01/${agentRun.backendProfile ?? "deepseek"}`, - capabilityLevel: "agentrun-v01-manual-dispatch", - sessionMode: "agentrun-v01-session-ref", - implementationType: "hwlab-cloud-api-agentrun-v01-adapter", + backend: CODEX_STDIO_BACKEND, + infrastructureBackend: `agentrun-v01/${agentRun.backendProfile ?? "deepseek"}`, + capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, agentRun: { ...agentRun, adapter: ADAPTER_ID, valuesPrinted: false }, valuesPrinted: false }; @@ -294,15 +361,17 @@ export function agentRunSessionEvidence(payload = {}) { terminalStatus: payload.agentRun.terminalStatus ?? null, lastSeq: payload.agentRun.lastSeq ?? 0, providerId: payload.agentRun.providerId ?? DEFAULT_PROVIDER_ID, + reuseEligible: payload.agentRun.reuseEligible ?? false, sessionId: payload.sessionId ?? payload.agentRun.sessionId ?? null, conversationId: payload.conversationId ?? payload.agentRun.conversationId ?? null, threadId: payload.threadId ?? payload.agentRun.threadId ?? null, + providerTrace: payload.providerTrace ?? payload.agentRun.providerTrace ?? null, valuesPrinted: false } }; } -function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile }) { +function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId }) { const commitId = fullSourceCommit(env); const resourceBundleRef = commitId ? { @@ -325,7 +394,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile }) { valuesPrinted: false }, sessionRef: { - sessionId: agentRunSessionId(traceId), + sessionId: sessionId ?? agentRunSessionIdForParams(params, traceId), ...(safeConversationId(params.conversationId) ? { conversationId: safeConversationId(params.conversationId) } : {}), metadata: { adapter: ADAPTER_ID, @@ -361,7 +430,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile }) { }; } -function buildAgentRunCommandInput({ params, traceId, backendProfile }) { +function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }) { const prompt = String(params.message ?? params.prompt ?? "").trim(); return { type: "turn", @@ -370,7 +439,7 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile }) { message: prompt, traceId, conversationId: safeConversationId(params.conversationId) || null, - sessionId: agentRunSessionId(traceId), + sessionId: sessionId ?? agentRunSessionIdForParams(params, traceId), hwlabSessionId: safeSessionId(params.sessionId) || null, threadId: null, requestedThreadId: safeOpaqueId(params.threadId) || null, @@ -409,6 +478,122 @@ function buildAgentRunTransientEnv(env = process.env) { return entries; } +async function resolveReusableAgentRun({ params = {}, options = {}, env = process.env, managerUrl, fetchImpl, timeoutMs, backendProfile, traceId, traceStore }) { + const sessionId = agentRunSessionIdForParams(params, traceId); + if (!safeSessionId(sessionId) || sessionId === agentRunSessionId(traceId) || typeof options.accessController?.getAgentSession !== "function") { + return null; + } + const session = await options.accessController.getAgentSession(sessionId); + if (!canReuseAgentRunSessionForOwner(session, params, options)) { + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:run:reuse-owner-skipped", + message: "Stored AgentRun session is not visible to the current actor; a new runner Job will be created for this request.", + sessionId, + waitingFor: "agentrun-run-create", + valuesPrinted: false + }); + return null; + } + const mapping = session?.session?.agentRun; + if (!isReusableAgentRunMapping(mapping, backendProfile)) { + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:run:reuse-skipped", + message: "No reusable AgentRun run was found for this HWLAB session; a new runner Job will be created.", + sessionId, + waitingFor: "agentrun-run-create", + valuesPrinted: false + }); + return null; + } + let run = null; + try { + run = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapping.runId)}`, { + method: "GET", + timeoutMs + }); + } catch (error) { + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:run:reuse-unavailable", + message: `Stored AgentRun run ${mapping.runId} could not be read; a new runner Job will be created.`, + errorCode: error?.code ?? "agentrun_run_lookup_failed", + runId: mapping.runId, + sessionId, + waitingFor: "agentrun-run-create", + valuesPrinted: false + }); + return null; + } + const reason = agentRunReuseBlocker(run, mapping); + if (reason) { + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:run:reuse-blocked", + message: `Stored AgentRun run ${mapping.runId} cannot be reused: ${reason}; a new runner Job will be created.`, + runId: mapping.runId, + sessionId, + waitingFor: "agentrun-run-create", + valuesPrinted: false + }); + return null; + } + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:run:reused", + message: `AgentRun run ${mapping.runId} is reused for HWLAB session ${sessionId}; runner lease is active and no new bundle will be requested for this turn.`, + runId: mapping.runId, + commandId: mapping.commandId ?? null, + runnerId: mapping.runnerId ?? null, + jobName: mapping.jobName ?? null, + namespace: mapping.namespace ?? null, + sessionId, + waitingFor: "agentrun-command-create", + valuesPrinted: false + }); + return { sessionId, session, mapping, run }; +} + +function isReusableAgentRunMapping(mapping, backendProfile) { + return Boolean( + mapping && + typeof mapping === "object" && + mapping.adapter === ADAPTER_ID && + mapping.reuseEligible !== false && + mapping.runId && + mapping.jobName && + (!mapping.backendProfile || mapping.backendProfile === backendProfile) + ); +} + +function canReuseAgentRunSessionForOwner(session, params = {}, options = {}) { + if (!session) return false; + const ownerUserId = options.actor?.id ?? params.ownerUserId ?? null; + const ownerRole = options.actor?.role ?? params.ownerRole ?? null; + if (ownerRole === "admin") return true; + if (!session.ownerUserId) return true; + return Boolean(ownerUserId && session.ownerUserId === ownerUserId); +} + +function agentRunReuseBlocker(run = {}, mapping = {}) { + const status = String(run?.terminalStatus ?? run?.status ?? mapping.runStatus ?? "").toLowerCase(); + if (TERMINAL_RUN_STATUSES.has(status)) return `run_status_${status}`; + if (!run?.claimedBy) return "runner_not_claimed"; + if (runLeaseExpired(run.leaseExpiresAt)) return "runner_lease_expired"; + return null; +} + +function runLeaseExpired(leaseExpiresAt) { + const expiresMs = Date.parse(String(leaseExpiresAt ?? "")); + return !Number.isFinite(expiresMs) || expiresMs <= Date.now(); +} + function providerCredentialSecretRef(profile, env) { return { profile, @@ -421,6 +606,57 @@ function providerCredentialSecretRef(profile, env) { }; } +function agentRunProviderTrace({ base = {}, result = {}, terminalStatus = null } = {}) { + return { + transport: "stdio", + protocol: CODEX_APP_SERVER_PROTOCOL, + wireApi: CODEX_APP_SERVER_WIRE_API, + runnerKind: CODEX_STDIO_RUNNER_KIND, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + command: "codex app-server --listen stdio://", + toolName: "agentrun.v01.command.turn", + source: ADAPTER_ID, + backendProfile: base.agentRun?.backendProfile ?? null, + runId: base.agentRun?.runId ?? result?.runId ?? null, + commandId: base.agentRun?.commandId ?? result?.commandId ?? null, + runnerId: base.agentRun?.runnerId ?? result?.runnerId ?? null, + jobName: base.agentRun?.jobName ?? result?.jobName ?? null, + namespace: base.agentRun?.namespace ?? result?.namespace ?? null, + traceId: base.traceId ?? base.agentRun?.traceId ?? null, + threadId: result?.sessionRef?.threadId ?? base.agentRun?.threadId ?? base.threadId ?? null, + turnId: result?.turnId ?? null, + terminalStatus: terminalStatus ?? result?.terminalStatus ?? null, + valuesPrinted: false + }; +} + +function agentRunLongLivedSessionGate(base = {}) { + return longLivedSessionGate({ + provider: CODEX_STDIO_PROVIDER, + runnerKind: CODEX_STDIO_RUNNER_KIND, + session: agentRunSessionSummary(base, "idle"), + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + codexStdioFeasibility: { + ready: true, + canStartLongLivedCodexStdio: true, + source: ADAPTER_ID, + valuesPrinted: false + } + }); +} + +function agentRunToolCalls(result = {}, status = "completed") { + return [{ + name: "codex-app-server.turn", + status, + runId: result?.runId ?? null, + commandId: result?.commandId ?? null, + outputSummary: result?.reply ? `assistantChars=${String(result.reply).length}` : null, + valuesPrinted: false + }]; +} + function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) { const terminalStatus = String(result?.terminalStatus ?? ""); const terminal = terminalStatus === "completed" || terminalStatus === "failed" || terminalStatus === "blocked" || terminalStatus === "cancelled"; @@ -444,11 +680,16 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) ...base, status: "completed", updatedAt: now, + workspace: base.workspace ?? "/home/agentrun/workspace", + sandbox: base.sandbox ?? "danger-full-access", session: agentRunSessionSummary(base, "idle"), + sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true), runner: agentRunRunnerSummary(base.agentRun), runnerTrace: traceStore.snapshot(traceId, agentRunTraceMeta({}, {})), - toolCalls: [], - skills: { status: "delegated", provider: ADAPTER_ID, items: [], valuesPrinted: false }, + toolCalls: agentRunToolCalls(result, "completed"), + skills: { status: "delegated", provider: ADAPTER_ID, count: 0, items: [], valuesPrinted: false }, + longLivedSessionGate: agentRunLongLivedSessionGate(base), + providerTrace: agentRunProviderTrace({ base, result, terminalStatus }), reply: { messageId: base.messageId ?? `msg_${traceId.slice(4)}`, role: "assistant", @@ -456,7 +697,7 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) createdAt: now }, usage: null, - agentRun: { ...base.agentRun, terminalStatus, completed: true, valuesPrinted: false }, + agentRun: { ...base.agentRun, terminalStatus, completed: true, reuseEligible: true, valuesPrinted: false }, valuesPrinted: false }; } @@ -480,10 +721,12 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) canceled, updatedAt: now, session: agentRunSessionSummary(base, canceled ? "canceled" : "failed"), + sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true), runner: agentRunRunnerSummary(base.agentRun), runnerTrace, - toolCalls: [], - skills: { status: "delegated", provider: ADAPTER_ID, items: [], valuesPrinted: false }, + toolCalls: agentRunToolCalls(result, canceled ? "canceled" : "failed"), + skills: { status: "delegated", provider: ADAPTER_ID, count: 0, items: [], valuesPrinted: false }, + providerTrace: agentRunProviderTrace({ base, result, terminalStatus }), error: { code, layer: "agentrun", @@ -518,6 +761,7 @@ function decorateAgentRunRunningResult({ base, mapping, traceStore, traceId }) { shortConnection: true, updatedAt: nowIso(), session: agentRunSessionSummary({ ...base, agentRun: mapping }, "running"), + sessionReuse: agentRunSessionReuseSummary({ ...base, agentRun: mapping }, mapping.reused === true), runner: agentRunRunnerSummary(mapping), runnerTrace: traceStore.snapshot(traceId, agentRunTraceMeta({}, {})), agentRun: { ...mapping, adapter: ADAPTER_ID, valuesPrinted: false }, @@ -646,20 +890,60 @@ function agentRunMapping({ env, managerUrl, backendProfile, run, command, runner terminalStatus: null, lastSeq: 0, traceId, + sessionId: run?.sessionRef?.sessionId ?? null, + conversationId: run?.sessionRef?.conversationId ?? null, + threadId: run?.sessionRef?.threadId ?? null, + reused: false, + reuseEligible: true, createdAt: startedAt, updatedAt: nowIso(), valuesPrinted: false }; } +function agentRunReusedMapping({ previous = {}, run = {}, command = {}, traceId, startedAt, backendProfile, managerUrl, env }) { + return { + ...previous, + adapter: ADAPTER_ID, + managerUrl, + backendProfile, + providerId: previous.providerId ?? firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID, env.AGENTRUN_PROVIDER_ID, DEFAULT_PROVIDER_ID), + runId: previous.runId ?? run?.id, + commandId: command.id, + status: "runner-job-reused", + runStatus: run?.status ?? previous.runStatus ?? null, + commandState: command.state ?? null, + terminalStatus: null, + lastSeq: previous.lastSeq ?? 0, + traceId, + sessionId: run?.sessionRef?.sessionId ?? previous.sessionId ?? null, + conversationId: run?.sessionRef?.conversationId ?? previous.conversationId ?? null, + threadId: run?.sessionRef?.threadId ?? previous.threadId ?? null, + reused: true, + reuseEligible: true, + createdAt: previous.createdAt ?? startedAt, + updatedAt: nowIso(), + valuesPrinted: false + }; +} + function agentRunResultRefs(result = {}) { const refs = {}; for (const key of ["attemptId", "runnerId", "jobName", "namespace", "runnerJobCount"]) { if (result?.[key] !== undefined && result?.[key] !== null) refs[key] = result[key]; } + if (result?.sessionRef && typeof result.sessionRef === "object") { + if (result.sessionRef.sessionId) refs.sessionId = result.sessionRef.sessionId; + if (result.sessionRef.conversationId) refs.conversationId = result.sessionRef.conversationId; + if (result.sessionRef.threadId) refs.threadId = result.sessionRef.threadId; + } return refs; } +function agentRunSessionIdForParams(params = {}, traceId) { + return safeSessionId(params.sessionId) || agentRunSessionId(traceId); +} + function agentRunSessionId(traceId) { return `ses_agentrun_${String(safeTraceId(traceId) || `trc_${randomUUID()}`).slice(4)}`; } @@ -670,16 +954,35 @@ function agentRunSessionSummary(base, status) { conversationId: base.conversationId ?? base.agentRun?.conversationId ?? null, threadId: base.threadId ?? base.agentRun?.threadId ?? null, status, - sessionMode: "agentrun-v01-session-ref", - runnerKind: "agentrun-v01-runner-job", + sessionMode: CODEX_STDIO_SESSION_MODE, + runnerKind: CODEX_STDIO_RUNNER_KIND, + lastTraceId: base.traceId ?? base.agentRun?.traceId ?? null, + longLivedSession: true, + codexStdio: true, + writeCapable: true, + durable: true, + durableSession: true, + idleTimeoutMs: parsePositiveInteger(base.agentRun?.idleTimeoutMs, 600000), secretMaterialStored: false, valuesRedacted: true }; } +function agentRunSessionReuseSummary(base, reused) { + return { + sessionId: base.sessionId ?? base.agentRun?.sessionId ?? null, + conversationId: base.conversationId ?? base.agentRun?.conversationId ?? null, + threadId: base.threadId ?? base.agentRun?.threadId ?? null, + reused: Boolean(reused), + status: reused ? "reused" : "new", + runnerReused: Boolean(reused), + valuesRedacted: true + }; +} + function agentRunRunnerSummary(mapping = {}) { return { - kind: "agentrun-v01-runner-job", + kind: CODEX_STDIO_RUNNER_KIND, adapter: ADAPTER_ID, runId: mapping.runId ?? null, commandId: mapping.commandId ?? null, @@ -688,17 +991,27 @@ function agentRunRunnerSummary(mapping = {}) { jobName: mapping.jobName ?? null, namespace: mapping.namespace ?? null, backendProfile: mapping.backendProfile ?? null, + provider: CODEX_STDIO_PROVIDER, + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, + codexStdio: true, + writeCapable: true, + durableSession: true, + longLivedSession: true, + sandbox: "danger-full-access", valuesPrinted: false }; } function agentRunTraceMeta(env = process.env) { return { - runnerKind: "agentrun-v01-runner-job", + runnerKind: CODEX_STDIO_RUNNER_KIND, workspace: resolveAgentRunRepoUrl(env), sandbox: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_SANDBOX, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "danger-full-access"), - sessionMode: "agentrun-v01-session-ref", - implementationType: "hwlab-cloud-api-agentrun-v01-adapter" + protocol: CODEX_APP_SERVER_PROTOCOL, + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE }; } diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 03ba970f..f1e9a480 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -139,6 +139,7 @@ test("cloud api /v1/agent/chat supports short submit and result polling", async test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapter", async () => { const calls = []; + const ownerSessions = new Map(); const agentRunServer = createHttpServer(async (request, response) => { const url = new URL(request.url || "/", "http://127.0.0.1"); const chunks = []; @@ -149,30 +150,46 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte response.writeHead(200, { "content-type": "application/json" }); response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_agentrun" })}\n`); }; + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter") { + return send({ + id: "run_hwlab_adapter", + status: "claimed", + terminalStatus: null, + claimedBy: "runner_hwlab_adapter", + leaseExpiresAt: "2999-01-01T00:00:00.000Z", + backendProfile: "deepseek", + sessionRef: { + sessionId: "ses_server-test-agentrun", + conversationId: "cnv_server-test-agentrun", + threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", + metadata: {} + } + }); + } if (request.method === "POST" && url.pathname === "/api/v1/runs") { assert.equal(body.tenantId, "hwlab"); assert.equal(body.backendProfile, "deepseek"); assert.equal(body.resourceBundleRef.repoUrl, "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"); assert.equal(body.resourceBundleRef.commitId, "0123456789abcdef0123456789abcdef01234567"); assert.equal(Object.hasOwn(body.sessionRef, "threadId"), false); - assert.equal(body.sessionRef.sessionId, "ses_agentrun_server-test-agentrun-adapter"); - assert.notEqual(body.sessionRef.sessionId, "ses_server-test-agentrun"); + assert.equal(body.sessionRef.sessionId, "ses_server-test-agentrun"); assert.equal(body.sessionRef.metadata.hwlabSessionId, "ses_server-test-agentrun"); assert.equal(body.sessionRef.metadata.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); - assert.equal(body.sessionRef.metadata.threadContinuityPolicy, "hwlab-agentrun-v01-new-runner-thread"); - assert.equal(body.sessionRef.metadata.sessionPolicy, "hwlab-agentrun-v01-run-local-session"); + 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 }); } if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands") { assert.equal(body.type, "turn"); assert.match(body.payload.prompt, /AgentRun adapter/u); - assert.equal(body.payload.sessionId, "ses_agentrun_server-test-agentrun-adapter"); + assert.equal(body.payload.sessionId, "ses_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.threadContinuityPolicy, "hwlab-agentrun-v01-new-runner-thread"); - assert.equal(body.payload.sessionPolicy, "hwlab-agentrun-v01-run-local-session"); - return send({ id: "cmd_hwlab_adapter", runId: "run_hwlab_adapter", state: "pending", type: "turn", seq: 1 }); + 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); + 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 === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/runner-jobs") { assert.equal(body.commandId, "cmd_hwlab_adapter"); @@ -194,10 +211,11 @@ 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"); return send({ items: [ - { 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_2", runId: "run_hwlab_adapter", seq: 2, type: "assistant_message", payload: { text: "AgentRun adapter 已接管 HWLAB Code Agent。" }, createdAt: "2026-06-01T00:00:01.000Z" }, - { id: "evt_3", runId: "run_hwlab_adapter", seq: 3, type: "terminal_status", payload: { terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:02.000Z" } + { 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" }, + { id: second ? "evt_5" : "evt_2", runId: "run_hwlab_adapter", seq: second ? 5 : 2, 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 : 3, type: "terminal_status", payload: { commandId: second ? "cmd_hwlab_adapter_second" : "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") { @@ -215,7 +233,27 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte completed: true, reply: "AgentRun adapter 已接管 HWLAB Code Agent。", lastSeq: 3, - eventCount: 3 + eventCount: 3, + sessionRef: { sessionId: "ses_server-test-agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" } + }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter_second/result") { + return send({ + runId: "run_hwlab_adapter", + commandId: "cmd_hwlab_adapter_second", + attemptId: "attempt_hwlab_adapter", + runnerId: "runner_hwlab_adapter", + jobName: "agentrun-v01-runner-hwlab-adapter", + namespace: "agentrun-v01", + status: "completed", + runStatus: "claimed", + commandState: "completed", + terminalStatus: "completed", + completed: true, + reply: "AgentRun adapter 复用已有 runner 完成第二轮。", + lastSeq: 6, + eventCount: 6, + sessionRef: { sessionId: "ses_server-test-agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" } }); } response.writeHead(404, { "content-type": "application/json" }); @@ -244,6 +282,13 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte }, async createCodeAgentDevicePodSession() { return { token: "test-device-pod-session-token" }; + }, + async recordAgentSessionOwner(input) { + ownerSessions.set(input.sessionId, input); + return input; + }, + async getAgentSession(sessionId) { + return ownerSessions.get(sessionId) ?? null; } } }); @@ -258,6 +303,8 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte body: JSON.stringify({ conversationId: "cnv_server-test-agentrun", sessionId: "ses_server-test-agentrun", + ownerUserId: "usr_agent_owner", + ownerRole: "user", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", retryOf: "trc_previous-agentrun-web", message: "AgentRun adapter smoke" @@ -270,8 +317,12 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte const payload = await pollAgentResult(port, traceId); validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "agentrun-v01"); - assert.equal(payload.backend, "agentrun-v01/deepseek"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.infrastructureBackend, "agentrun-v01/deepseek"); + assert.equal(payload.providerTrace.protocol, "codex-app-server-jsonrpc-stdio"); + assert.equal(payload.providerTrace.command, "codex app-server --listen stdio://"); + assert.equal(payload.providerTrace.terminalStatus, "completed"); + assert.equal(payload.longLivedSessionGate.status, "pass"); assert.equal(payload.agentRun.runId, "run_hwlab_adapter"); assert.equal(payload.agentRun.commandId, "cmd_hwlab_adapter"); assert.equal(payload.agentRun.jobName, "agentrun-v01-runner-hwlab-adapter"); @@ -284,6 +335,33 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte const traceBody = await trace.json(); assert.ok(traceBody.events.some((event) => event.runId === "run_hwlab_adapter")); assert.ok(traceBody.events.some((event) => event.label === "agentrun:assistant:message")); + + const secondTraceId = "trc_server-test-agentrun-adapter-second"; + const second = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { "content-type": "application/json", "x-trace-id": secondTraceId }, + body: JSON.stringify({ + conversationId: "cnv_server-test-agentrun", + sessionId: "ses_server-test-agentrun", + ownerUserId: "usr_agent_owner", + ownerRole: "user", + threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", + message: "AgentRun adapter smoke 第二轮" + }) + }); + assert.equal(second.status, 202); + const secondPayload = await pollAgentResult(port, secondTraceId); + validateCodeAgentChatSchema(secondPayload); + assert.equal(secondPayload.status, "completed"); + assert.equal(secondPayload.agentRun.runId, "run_hwlab_adapter"); + assert.equal(secondPayload.agentRun.commandId, "cmd_hwlab_adapter_second"); + assert.equal(secondPayload.agentRun.jobName, "agentrun-v01-runner-hwlab-adapter"); + assert.equal(secondPayload.sessionReuse.reused, true); + 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(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); } 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()))); diff --git a/web/hwlab-cloud-web/code-agent-facts.test.ts b/web/hwlab-cloud-web/code-agent-facts.test.ts index 7f19c036..07ec7d72 100644 --- a/web/hwlab-cloud-web/code-agent-facts.test.ts +++ b/web/hwlab-cloud-web/code-agent-facts.test.ts @@ -130,6 +130,75 @@ test("long-lived Codex app-server stdio session fields are shown as full Code Ag assert.match(runtimePath.summary, /Responses wire API/u); }); +test("AgentRun carried Codex app-server session is shown as full Code Agent session", () => { + const message = { + status: "completed", + provider: "codex-stdio", + model: "deepseek-chat", + backend: "hwlab-cloud-api/codex-app-server-stdio", + infrastructureBackend: "agentrun-v01/deepseek", + workspace: "/home/agentrun/workspace", + sandbox: "danger-full-access", + capabilityLevel: "long-lived-codex-stdio-session", + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session", + session: { + sessionId: "ses_web_agentrun", + threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", + status: "idle", + lastTraceId: "trc_web_agentrun", + longLivedSession: true, + codexStdio: true, + writeCapable: true, + durable: true, + durableSession: true + }, + sessionReuse: { + sessionId: "ses_web_agentrun", + threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", + reused: true + }, + runner: { + kind: "codex-app-server-stdio-runner", + codexStdio: true, + writeCapable: true, + durableSession: true, + longLivedSession: true + }, + longLivedSessionGate: { + status: "pass", + pass: true + }, + toolCalls: [{ name: "codex-app-server.turn", status: "completed" }], + skills: { status: "delegated", count: 0 }, + runnerTrace: { + traceId: "trc_web_agentrun", + runnerKind: "codex-app-server-stdio-runner", + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session" + }, + providerTrace: { + transport: "stdio", + protocol: "codex-app-server-jsonrpc-stdio", + wireApi: "responses", + command: "codex app-server --listen stdio://", + terminalStatus: "completed", + source: "agentrun-v01" + }, + traceId: "trc_web_agentrun" + }; + const facts = codeAgentFactsFromMessage(message); + const runtimePath = codeAgentRuntimePathFromMessage({ role: "agent", ...message }); + + assert.equal(facts.kind, "codex-stdio-long-lived"); + assert.equal(facts.fullCodeAgent, true); + assert.equal(runtimePath.kind, "codex-app-server-stdio"); + assert.equal(runtimePath.tone, "ok"); + assert.equal(runtimePath.providerTraceMissing, false); + assert.equal(runtimePath.criticalMissing, false); + assert.equal(runtimePath.terminalStatusOk, true); +}); + test("Codex app-server without providerTrace is degraded with structured missing fields", () => { const message = { role: "agent",