fix: 统一 AgentRun threadId 上下文连续性
This commit is contained in:
@@ -155,11 +155,10 @@ workspace;除非 Pod 重建、Codex supervisor 重启、用户显式清空对
|
||||
可用时优先使用;server 内存 session 因 Pod/replica 切换丢失时,只要本地 session 未过期,
|
||||
刷新页面不得要求用户重新登录。
|
||||
|
||||
当后端使用 AgentRun v0.1 短连接 runner 时,Web 仍可把历史 `threadId` 作为会话连续性元数据
|
||||
提交,但 HWLAB adapter 不得把该历史 `threadId` 直接透传给新 runner 做 Codex app-server
|
||||
`thread/resume`。AgentRun runner Job 是新执行面,旧 Web/Codex thread 可能没有对应 rollout;
|
||||
adapter 应保留 `conversationId/sessionId` 和 redacted `requestedThreadId` 用于追踪,同时让新
|
||||
runner 建立自己的 app-server thread,避免 `no rollout found for thread id`。
|
||||
当后端使用 AgentRun v0.1 短连接 runner 时,Web 必须把当前 conversation 的 `threadId` 作为
|
||||
唯一会话连续性字段提交;HWLAB adapter 必须把它写入 AgentRun command `payload.threadId`
|
||||
和 `SessionRef.threadId`,runner 只按该标准字段执行 Codex app-server `thread/resume`。resume 失败
|
||||
必须作为标准 `threadId` 路径上的结构化错误/trace 事件暴露。
|
||||
|
||||
恢复出的历史消息如果携带 `traceId`,前端必须先对 `/v1/agent/chat/result/<traceId>` 做
|
||||
终态归一,再回放 `/trace`。旧的 `running` 消息不能只因为 localStorage restore 被降级成
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
- CLI 本地登录态必须支持 `--profile NAME` 隔离,同一 base URL 下不同 profile 写入 `.state/hwlab-cli/profiles/<base-url-hash>/<profile>.json`。切换到其他账号再切回原账号时,`client workbench restore/status` 必须从服务端账号 workspace 恢复之前的 `workspaceId`、`conversationId`、`sessionId`、`threadId`、`activeTraceId` 和 revision,而不是只依赖本地文件。
|
||||
- `client workbench restore/status/watch/reset` 是账号 workspace 的非视觉入口:`restore/status` 对应 `GET /v1/workbench/workspace`,`watch` 对应 `/events?afterRevision=`,`reset --confirm` 对应服务端 reset。输出必须显示 workspace revision、selected conversation/session、active trace 和本地 state file,且不得保存 password、session token 原文以外的 Secret 值。
|
||||
- `client agent send` 是 Cloud Web Code Agent composer 的非视觉等价入口。它必须支持 `--from-trace`、`--conversation-id`、`--session-id`、`--thread-id` 和 `--retry-of`,并在输出中返回 redacted continuation 摘要,证明本次 CLI 请求是否覆盖 Web 的继续会话路径。浏览器 issue 中已经给出 traceId 时,复现命令优先使用 `--from-trace <traceId>`,让 CLI 先走 `/v1/agent/chat/inspect` 读取 Web 上下文,再提交同源 `/v1/agent/chat`。
|
||||
- Code Agent continuation 的 thread 字段只有 `threadId` 一个标准名称。CLI 读取 inspect、`--from-trace` 回放、手动 `--thread-id` 提交和输出摘要都必须以该字段为唯一 thread identity;服务端响应也应保持同一字段口径。
|
||||
- `client agent send` 默认先恢复账号 workspace,再向 `/v1/agent/chat` 发送 `workspaceId` 和 `expectedWorkspaceRevision`;服务端接受后 CLI 保存新的 workspace revision,终态轮询后再 PATCH workspace 清理终态 `activeTraceId`。只有显式 `--no-workspace` 才跳过这一默认恢复路径。
|
||||
- `client agent trace <traceId> --render web` 必须调用 Cloud Web trace row 的同一纯转换路径,输出 `render="web"`、renderer 标识、source event count、rendered row count、默认压制的 noise event count 和 row 摘要。浏览器 trace 展示错乱时,必须先用该 CLI 入口确认 Web 渲染转换是否已经乱序、重复、缺 final response、吞掉关键 row 或只显示泛化 tool call,再继续修浏览器 DOM/CSS。
|
||||
- AgentRun v0.1 短连接 runner 已要求支持同 run/runner 多轮 command。CLI 仍应把 Web 提交的 `conversationId/sessionId/threadId` 原样送到 Cloud Web API,用于验证 adapter 是否在 runner lease 有效时复用同一个 AgentRun `runId` / `jobName` 并创建新 `commandId`;每轮都新建 runner 或重新 bundle 不是通过状态,trace 中的原因说明只能用于定位。
|
||||
@@ -86,6 +87,10 @@
|
||||
|
||||
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:对一个来自 Cloud Web 的失败 trace 运行 `client agent send --from-trace <traceId> --message "重试上一条" --provider-profile deepseek --no-wait`,确认 CLI 先访问 `/v1/agent/chat/inspect?traceId=<traceId>`,再提交 `/v1/agent/chat`,输出包含 `replay.source`、`continuation.replayedFromTrace`、`conversationId/sessionId/threadId/retryOf`,且不输出 cookie、token 或 secret 原文。
|
||||
|
||||
## T5.1
|
||||
|
||||
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:复测取消后追问场景时,`client agent send/result/trace --render web` 输出只允许出现标准 `threadId`,不允许出现历史 thread 别名字段;第二轮必须是新 `commandId` 且 trace row 不包含上一 command 的尾部 assistant/tool/terminal 文本。
|
||||
|
||||
## T3A
|
||||
|
||||
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:分别用 `--profile admin-a` 和 `--profile admin-a-second` 登录同一个账号,运行 `client workbench restore/status`,确认两个 profile 看到相同 `workspaceId` 和 revision;再用 `--profile other-user` 登录另一个账号,确认 workspace 不同;最后切回 `admin-a`,确认原 workspace、conversation/session/thread 仍能恢复。该验收必须使用真实 `http://74.48.78.17:19666`,不能 mock。
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
- `internal/cloud/access-control.ts` 也是账号 workspace authority:`account_workspaces` 记录同一账号的 Workbench 当前 workspace、selected conversation/session、active trace、provider profile 和 revision。
|
||||
- `internal/db/runtime-store.ts` 和 `internal/cloud/db-contract.ts` 负责 Postgres runtime store 与 readiness 分层。
|
||||
- `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。
|
||||
- AgentRun v0.1 接入只使用标准 `threadId` 路径:`POST /v1/agent/chat` 收到的 `conversationId/sessionId/threadId` 必须写入 AgentRun command `payload.threadId` 和 `SessionRef.threadId`;协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity。
|
||||
- AgentRun run 级 events 写回 HWLAB trace 时必须按当前 `commandId` 归属过滤;同一 run 的旧 command 尾部事件不能混入后续 command trace。取消、失败或 blocked 轮次如果已有 assistant/tool 可读上下文,必须以脱敏、限长的 partial context 写入 conversation facts,供后续 `inspect`/`--from-trace` 和同一 thread 的下一轮使用。
|
||||
- 传给 AgentRun runner Job 的 `transientEnv` 是短期运行上下文,数量不设固定 8 项上限;新增项必须仍满足最小必要、脱敏和 Secret 值不打印原则。
|
||||
- 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。
|
||||
- `hwlab-code-agent-workspace` PVC 挂载到 `/workspace/hwlab`,用于长会话 workspace;它是 cloud-api 运行资源,不是独立用户入口。
|
||||
|
||||
@@ -51,6 +54,10 @@
|
||||
|
||||
阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:使用短连接 `POST /v1/agent/chat` 提交一次对话,再轮询 `/v1/agent/chat/result/{traceId}`,只有 `status=completed` 且 assistant reply 非空才算 Code Agent 通过。
|
||||
|
||||
## T2.1
|
||||
|
||||
阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:在同一 `conversationId/sessionId/threadId` 中先发起一轮会产生可见 assistant/tool 进展的 Code Agent 请求并取消,再发送“回答一下刚才调查结果”这类后续消息;第二轮必须继续使用同一个标准 `threadId`,且 trace/result 不能混入上一 command 的尾部事件。
|
||||
|
||||
## T3
|
||||
|
||||
阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:未登录访问 `/v1/device-pods` 必须返回认证错误;登录后访问 device-pod list/status 时必须显示 `contractVersion=device-pod-authority-v1` 和 `fake=false` 来源,不得出现 fake fallback。
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
- Cloud Web trace 展示与 `hwlab-cli client agent trace --render web` 必须共享同一套 trace row 纯转换路径。Web 发生 row 顺序错乱、final response 缺失、assistant 消息被吞、tool call 只显示泛化占位或噪声事件淹没时,先用 CLI 输出同一渲染 row 摘要和 noise event count 复现;CLI 可复现说明是 trace row 转换问题,CLI 不可复现再进入 DOM/CSS/滚动状态调查。默认展示应压制 AgentRun backend echo、token/rate-limit/status/terminal echo 等低价值事件,但原始 trace JSON 仍必须保留用于 `--full`/下载排障。
|
||||
- Code Agent result `completed` 只有在同时包含真实 provider/model/trace/conversation 元数据、`providerTrace` 和可展示的 final assistant response 时,才能被 Web 标记为真实完成;`provider=agentrun-v01` 只是执行基础设施标识,不得替代上游 provider/model,也不得把 SOURCE、fixture、echo、mock 或 stub 当成 DEV-LIVE 完成。
|
||||
- 同一 conversation/session 的后续用户消息必须在 AgentRun runner lease 有效时复用已存在的 AgentRun run/runner 继续新 command/turn;只有 runner 不可用、已过期或协议明确要求新 runner 时才重新 bundle 和启动 runner。每条消息都重新 bundle/runner 属于 v0.2 AgentRun 接入缺口,不能只靠 trace 显示原因当成已完成。
|
||||
- AgentRun 会话连续性只有一个标准路径:Cloud Web/CLI 提交的 `threadId` 必须经 Cloud API adapter 写入 AgentRun command `payload.threadId` 和 `SessionRef.threadId`。前端、CLI、API 和 AgentRun 的协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity。
|
||||
- 同一 AgentRun run 复用多条 command 时,Web trace 展示只显示当前 command 归属事件和必要 run 级状态;旧 command 的 assistant/tool/terminal 尾部不能堆到新 command 末尾。取消轮次的可读进展必须作为脱敏 partial context 进入 conversation facts 和标准 thread,而不是靠旧 trace 尾部串线让后续轮次“碰巧看到”。
|
||||
|
||||
## 内部架构
|
||||
|
||||
@@ -59,6 +61,10 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验,
|
||||
|
||||
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:在同一 conversation/session 连续发送两条 Code Agent 消息,确认第二条复用第一条的 AgentRun `runId` 和 runner `jobName`、生成新的 `commandId`,且不重新 materialize bundle/启动新 runner;result completed 必须包含真实 provider/model/`providerTrace`/trace/conversation 和 final assistant response。复用失败原因只能作为诊断,不作为本测试通过条件。
|
||||
|
||||
## T2.3
|
||||
|
||||
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:对一轮已取消但取消前存在 assistant/tool 进展的 trace 发送同一 conversation/session/thread 后续问题,确认请求和 result 只携带标准 `threadId`,不出现历史 thread 别名字段;`--render web` 输出不得把上一 command 的尾部 assistant/tool/terminal row 堆到新 command 末尾。
|
||||
|
||||
## T3
|
||||
|
||||
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:打开 Workbench device-pod 面板,确认 status/freshness/blocker 显示来自 `/v1/device-pods`,未登录或未授权时必须显示认证/授权 blocker,不得把 fixture 或 blocked fallback 写成真实硬件 DEV-LIVE。
|
||||
|
||||
@@ -64,7 +64,6 @@ export function initialAgentRunChatResult({ params = {}, options = {}, traceId }
|
||||
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
|
||||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||||
requestedThreadId: safeOpaqueId(params.threadId) || null,
|
||||
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
|
||||
agentRun: {
|
||||
adapter: ADAPTER_ID,
|
||||
@@ -376,6 +375,7 @@ export function agentRunSessionEvidence(payload = {}) {
|
||||
|
||||
function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId }) {
|
||||
const commitId = fullSourceCommit(env);
|
||||
const threadId = safeOpaqueId(params.threadId);
|
||||
const resourceBundleRef = commitId
|
||||
? {
|
||||
kind: "git",
|
||||
@@ -400,12 +400,12 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses
|
||||
sessionRef: {
|
||||
sessionId: sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile),
|
||||
...(safeConversationId(params.conversationId) ? { conversationId: safeConversationId(params.conversationId) } : {}),
|
||||
...(threadId ? { threadId } : {}),
|
||||
metadata: {
|
||||
adapter: ADAPTER_ID,
|
||||
hwlabTraceId: traceId,
|
||||
hwlabApi: "/v1/agent/chat",
|
||||
hwlabSessionId: safeSessionId(params.sessionId) || null,
|
||||
requestedThreadId: safeOpaqueId(params.threadId) || null,
|
||||
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
|
||||
sessionPolicy: SESSION_POLICY_RUN_LOCAL,
|
||||
agentRunSessionProfile: backendProfile,
|
||||
@@ -438,6 +438,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses
|
||||
|
||||
function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }) {
|
||||
const prompt = String(params.message ?? params.prompt ?? "").trim();
|
||||
const threadId = safeOpaqueId(params.threadId);
|
||||
return {
|
||||
type: "turn",
|
||||
payload: {
|
||||
@@ -447,8 +448,7 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId
|
||||
conversationId: safeConversationId(params.conversationId) || null,
|
||||
sessionId: sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile),
|
||||
hwlabSessionId: safeSessionId(params.sessionId) || null,
|
||||
threadId: null,
|
||||
requestedThreadId: safeOpaqueId(params.threadId) || null,
|
||||
threadId: threadId || null,
|
||||
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
|
||||
sessionPolicy: SESSION_POLICY_RUN_LOCAL,
|
||||
providerProfile: backendProfile,
|
||||
@@ -734,6 +734,7 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId })
|
||||
});
|
||||
const code = canceled ? "agentrun_canceled" : result?.failureKind ?? (terminalStatus === "blocked" ? "agentrun_blocked" : "agentrun_failed");
|
||||
const message = result?.failureMessage ?? result?.blocker?.message ?? (canceled ? "AgentRun command was canceled" : "AgentRun command failed");
|
||||
const partialContext = partialAgentRunContext(runnerTrace);
|
||||
return {
|
||||
...base,
|
||||
status: canceled ? "canceled" : "failed",
|
||||
@@ -742,7 +743,7 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId })
|
||||
session: agentRunSessionSummary(base, canceled ? "canceled" : "failed"),
|
||||
sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true),
|
||||
runner: agentRunRunnerSummary(base.agentRun),
|
||||
runnerTrace,
|
||||
runnerTrace: partialContext ? { ...runnerTrace, partialContext } : runnerTrace,
|
||||
toolCalls: agentRunToolCalls(result, canceled ? "canceled" : "failed"),
|
||||
skills: { status: "delegated", provider: ADAPTER_ID, count: 0, items: [], valuesPrinted: false },
|
||||
providerTrace,
|
||||
@@ -768,6 +769,28 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId })
|
||||
toolName: "agentrun.manual-dispatch"
|
||||
},
|
||||
agentRun: { ...base.agentRun, terminalStatus, completed: false, providerTrace, valuesPrinted: false },
|
||||
...(partialContext ? { partialContext } : {}),
|
||||
valuesPrinted: false
|
||||
};
|
||||
}
|
||||
|
||||
function partialAgentRunContext(runnerTrace = {}) {
|
||||
const events = Array.isArray(runnerTrace.events) ? runnerTrace.events : [];
|
||||
const assistantMessages = events
|
||||
.filter((event) => event?.type === "assistant" && String(event.text ?? event.message ?? "").trim())
|
||||
.map((event) => String(event.text ?? event.message).trim())
|
||||
.slice(-4);
|
||||
const toolEvidence = events
|
||||
.filter((event) => event?.type === "tool_call" && String(event.status ?? "") === "completed")
|
||||
.map((event) => [event.toolName ?? event.label ?? "tool", event.command ?? event.outputSummary ?? event.stdoutSummary ?? event.message ?? ""].filter(Boolean).join(": "))
|
||||
.filter(Boolean)
|
||||
.slice(-6);
|
||||
if (assistantMessages.length === 0 && toolEvidence.length === 0) return null;
|
||||
return {
|
||||
status: "partial-before-terminal",
|
||||
summary: "AgentRun terminal 前已有可延续的 assistant/tool 上下文;后续同 conversation/session/thread 轮次必须能继续使用。",
|
||||
assistantMessages,
|
||||
toolEvidence,
|
||||
valuesPrinted: false
|
||||
};
|
||||
}
|
||||
@@ -795,11 +818,20 @@ function decorateAgentRunRunningResult({ base, mapping, traceStore, traceId }) {
|
||||
|
||||
function appendAgentRunEventsToTrace(traceStore, traceId, events, mapping = {}) {
|
||||
for (const event of events) {
|
||||
if (isForeignAgentRunCommandEvent(event, mapping)) continue;
|
||||
const normalized = mapAgentRunEvent(event, mapping);
|
||||
if (normalized) traceStore.append(traceId, normalized, agentRunTraceMeta({}, {}));
|
||||
}
|
||||
}
|
||||
|
||||
function isForeignAgentRunCommandEvent(event, mapping = {}) {
|
||||
const currentCommandId = typeof mapping.commandId === "string" ? mapping.commandId : "";
|
||||
if (!currentCommandId) return false;
|
||||
const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
|
||||
const eventCommandId = typeof payload.commandId === "string" ? payload.commandId : "";
|
||||
return Boolean(eventCommandId && eventCommandId !== currentCommandId);
|
||||
}
|
||||
|
||||
function mapAgentRunEvent(event, mapping = {}) {
|
||||
if (!event || typeof event !== "object") return null;
|
||||
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
|
||||
|
||||
@@ -245,7 +245,10 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations;
|
||||
if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility;
|
||||
if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate;
|
||||
if (error.conversationFacts !== undefined) {
|
||||
const factPayload = shouldRecordErrorConversationFact(payload) ? attachConversationFacts(decorateChatSessionLifecycle(payload), sessionRegistry, { now: options.now }) : null;
|
||||
if (factPayload?.conversationFacts) {
|
||||
payload.conversationFacts = factPayload.conversationFacts;
|
||||
} else if (error.conversationFacts !== undefined) {
|
||||
payload.conversationFacts = error.conversationFacts;
|
||||
} else if (typeof sessionRegistry.getConversationFacts === "function") {
|
||||
payload.conversationFacts = sessionRegistry.getConversationFacts(conversationId);
|
||||
@@ -263,6 +266,14 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRecordErrorConversationFact(payload = {}) {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
if (payload.status === "running" || payload.status === "completed") return false;
|
||||
if (payload.partialContext) return true;
|
||||
if (payload.runnerTrace?.partialContext) return true;
|
||||
return Boolean(payload.runnerTrace?.traceId && payload.sessionId && payload.conversationId);
|
||||
}
|
||||
|
||||
function completedRunnerPayload({ base, runnerResult, messageId, now, sessionRegistry }) {
|
||||
const completedAt = nowIso(now);
|
||||
const blocker = structuredCompletionBlocker(runnerResult, {
|
||||
|
||||
@@ -477,11 +477,31 @@ function sanitizeConversationFact(fact, { conversationId, timestamp, maxFactTool
|
||||
toolCalls,
|
||||
skills,
|
||||
runnerTrace: sanitizeRunnerTrace(runnerTrace),
|
||||
partialContext: sanitizePartialContext(fact.partialContext ?? fact.partialConversationContext ?? runnerTrace?.partialContext),
|
||||
valuesRedacted: true,
|
||||
secretMaterialStored: false
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizePartialContext(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const assistantMessages = Array.isArray(value.assistantMessages)
|
||||
? value.assistantMessages.map((item) => safeFactText(item, 240)).filter(Boolean).slice(-4)
|
||||
: [];
|
||||
const toolEvidence = Array.isArray(value.toolEvidence)
|
||||
? value.toolEvidence.map((item) => safeFactText(item, 200)).filter(Boolean).slice(-6)
|
||||
: [];
|
||||
const summary = safeFactText(value.summary, 400);
|
||||
if (!summary && assistantMessages.length === 0 && toolEvidence.length === 0) return null;
|
||||
return {
|
||||
status: safeFactText(value.status ?? "partial", 40) ?? "partial",
|
||||
summary,
|
||||
assistantMessages,
|
||||
toolEvidence,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeToolCalls(toolCalls, limit) {
|
||||
const items = Array.isArray(toolCalls)
|
||||
? toolCalls.slice(0, limit).map((toolCall) => ({
|
||||
@@ -544,6 +564,7 @@ function sanitizeRunnerTrace(runnerTrace) {
|
||||
: [],
|
||||
route: safeFactText(runnerTrace.route, 120),
|
||||
status: safeFactText(runnerTrace.status, 80),
|
||||
partialContext: sanitizePartialContext(runnerTrace.partialContext),
|
||||
outputTruncated: runnerTrace.outputTruncated === true,
|
||||
valuesPrinted: false
|
||||
};
|
||||
|
||||
@@ -1143,7 +1143,7 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const conversationId = requiredId(params.conversationId, "cnv");
|
||||
const requestedSessionId = optionalId(params.requestedSessionId);
|
||||
const requestedThreadId = optionalId(params.threadId);
|
||||
const threadId = optionalId(params.threadId);
|
||||
const mappedSessionId = conversations.get(conversationId) ?? null;
|
||||
|
||||
if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) {
|
||||
@@ -1162,7 +1162,7 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
let reused = Boolean(session);
|
||||
|
||||
if (session && sessionExpired(session, timestampMs)) {
|
||||
const resumeThreadId = requestedThreadId ?? optionalId(session.threadId);
|
||||
const resumeThreadId = threadId ?? optionalId(session.threadId);
|
||||
if (["idle", "expired"].includes(session.status) && resumeThreadId) {
|
||||
session.status = "idle";
|
||||
session.threadId = resumeThreadId;
|
||||
@@ -1241,7 +1241,7 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
lastTraceId: optionalId(params.traceId),
|
||||
currentTraceId: null,
|
||||
turn: 0,
|
||||
threadId: requestedThreadId,
|
||||
threadId,
|
||||
durable: true,
|
||||
longLivedSession: true,
|
||||
codexStdio: true,
|
||||
@@ -1249,11 +1249,11 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
secretMaterialStored: false
|
||||
};
|
||||
sessions.set(effectiveSessionId, session);
|
||||
reused = Boolean(requestedThreadId);
|
||||
reused = Boolean(threadId);
|
||||
}
|
||||
|
||||
session.conversationIds.add(conversationId);
|
||||
session.threadId = requestedThreadId ?? session.threadId;
|
||||
session.threadId = threadId ?? session.threadId;
|
||||
session.workspace = params.workspace ?? session.workspace;
|
||||
session.sandbox = params.sandbox ?? session.sandbox;
|
||||
session.status = "busy";
|
||||
|
||||
@@ -174,12 +174,11 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
|
||||
assert.deepEqual(body.resourceBundleRef.toolAliases, [
|
||||
{ name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" }
|
||||
]);
|
||||
assert.equal(Object.hasOwn(body.sessionRef, "threadId"), false);
|
||||
assert.equal(body.sessionRef.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
|
||||
assert.equal(body.sessionRef.sessionId, "ses_agentrun_deepseek_server_test_agentrun");
|
||||
assert.equal(body.sessionRef.metadata.hwlabSessionId, "ses_server-test-agentrun");
|
||||
assert.equal(body.sessionRef.metadata.agentRunSessionProfile, "deepseek");
|
||||
assert.equal(body.sessionRef.metadata.agentRunSessionPolicy, "backend-profile-scoped");
|
||||
assert.equal(body.sessionRef.metadata.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
|
||||
assert.equal(body.sessionRef.metadata.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread");
|
||||
assert.equal(body.sessionRef.metadata.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse");
|
||||
return send({ id: "run_hwlab_adapter", status: "pending", backendProfile: "deepseek", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef });
|
||||
@@ -189,8 +188,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
|
||||
assert.match(body.payload.prompt, /AgentRun adapter/u);
|
||||
assert.equal(body.payload.sessionId, "ses_agentrun_deepseek_server_test_agentrun");
|
||||
assert.equal(body.payload.hwlabSessionId, "ses_server-test-agentrun");
|
||||
assert.equal(body.payload.threadId, null);
|
||||
assert.equal(body.payload.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
|
||||
assert.equal(body.payload.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
|
||||
assert.equal(body.payload.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread");
|
||||
assert.equal(body.payload.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse");
|
||||
const secondTurn = /第二轮/u.test(body.payload.prompt);
|
||||
@@ -217,7 +215,6 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
|
||||
assert.equal(transientEnv.HWLAB_DEVICE_POD_SESSION_TOKEN, "test-device-pod-session-token");
|
||||
assert.equal(Object.hasOwn(transientEnv, "HWLAB_DEVICE_POD_API_URL"), false);
|
||||
assert.equal(Object.hasOwn(transientEnv, "HWLAB_CODE_AGENT_DEVICE_POD_API_URL"), false);
|
||||
assert.ok(body.transientEnv.length <= 8);
|
||||
return send({
|
||||
action: "create-kubernetes-job",
|
||||
runId: "run_hwlab_adapter",
|
||||
@@ -231,15 +228,20 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
|
||||
});
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/events") {
|
||||
const second = url.searchParams.get("afterSeq") === "3" || calls.some((call) => call.path === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter_second/result");
|
||||
const afterSeq = Number.parseInt(url.searchParams.get("afterSeq") ?? "0", 10);
|
||||
const second = afterSeq >= 5 || url.searchParams.get("afterSeq") === "3" || calls.some((call) => call.path === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter_second/result");
|
||||
if (second) return send({ items: [
|
||||
{ id: "evt_old_tail", runId: "run_hwlab_adapter", seq: 6, type: "assistant_message", payload: { commandId: "cmd_hwlab_adapter", text: "旧 command 尾部不应进入第二轮。" }, createdAt: "2026-06-01T00:00:02.500Z" },
|
||||
{ id: "evt_7", runId: "run_hwlab_adapter", seq: 7, type: "backend_status", payload: { phase: "turn-started", commandId: "cmd_hwlab_adapter_second", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:03.000Z" },
|
||||
{ id: "evt_8", runId: "run_hwlab_adapter", seq: 8, type: "assistant_message", payload: { commandId: "cmd_hwlab_adapter_second", text: "AgentRun adapter 复用已有 runner 完成第二轮。" }, createdAt: "2026-06-01T00:00:04.000Z" },
|
||||
{ id: "evt_9", runId: "run_hwlab_adapter", seq: 9, type: "terminal_status", payload: { commandId: "cmd_hwlab_adapter_second", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:05.000Z" }
|
||||
] });
|
||||
return send({ items: [
|
||||
{ id: second ? "evt_4" : "evt_1", runId: "run_hwlab_adapter", seq: second ? 4 : 1, type: "backend_status", payload: { phase: second ? "turn-started" : "runner-job-created", commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:00.000Z" },
|
||||
...(second ? [] : [
|
||||
{ id: "evt_1", runId: "run_hwlab_adapter", seq: 1, type: "backend_status", payload: { phase: "runner-job-created", commandId: "cmd_hwlab_adapter", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:00.000Z" },
|
||||
{ id: "evt_tool", runId: "run_hwlab_adapter", seq: 2, type: "tool_call", payload: { method: "item/completed", item: { type: "commandExecution", id: "call_agentrun_tool", command: "/bin/sh -lc 'hwpod profile list'", status: "completed", exitCode: 0, durationMs: 708, aggregatedOutput: '{"ok":true,"action":"profile.list"}' }, summary: { outputBytes: 42, outputTruncated: false }, commandId: "cmd_hwlab_adapter", runnerId: "runner_hwlab_adapter", attemptId: "attempt_hwlab_adapter" }, createdAt: "2026-06-01T00:00:00.500Z" },
|
||||
{ id: "evt_noise", runId: "run_hwlab_adapter", seq: 3, type: "backend_status", payload: { phase: "thread/status/changed", commandId: "cmd_hwlab_adapter" }, createdAt: "2026-06-01T00:00:00.750Z" }
|
||||
]),
|
||||
{ id: second ? "evt_5" : "evt_2", runId: "run_hwlab_adapter", seq: second ? 5 : 4, type: "assistant_message", payload: { commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", text: second ? "AgentRun adapter 复用已有 runner 完成第二轮。" : "AgentRun adapter 已接管 HWLAB Code Agent。" }, createdAt: "2026-06-01T00:00:01.000Z" },
|
||||
{ id: second ? "evt_6" : "evt_3", runId: "run_hwlab_adapter", seq: second ? 6 : 5, type: "terminal_status", payload: { commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:02.000Z" }
|
||||
{ id: "evt_noise", runId: "run_hwlab_adapter", seq: 3, type: "backend_status", payload: { phase: "thread/status/changed", commandId: "cmd_hwlab_adapter" }, createdAt: "2026-06-01T00:00:00.750Z" },
|
||||
{ id: "evt_2", runId: "run_hwlab_adapter", seq: 4, type: "assistant_message", payload: { commandId: "cmd_hwlab_adapter", text: "AgentRun adapter 已接管 HWLAB Code Agent。" }, createdAt: "2026-06-01T00:00:01.000Z" },
|
||||
{ id: "evt_3", runId: "run_hwlab_adapter", seq: 5, type: "terminal_status", payload: { commandId: "cmd_hwlab_adapter", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:02.000Z" }
|
||||
] });
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter/result") {
|
||||
@@ -275,8 +277,8 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
|
||||
terminalStatus: "completed",
|
||||
completed: true,
|
||||
reply: "AgentRun adapter 复用已有 runner 完成第二轮。",
|
||||
lastSeq: 6,
|
||||
eventCount: 6,
|
||||
lastSeq: 9,
|
||||
eventCount: 9,
|
||||
sessionRef: { sessionId: "ses_agentrun_deepseek_server_test_agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" }
|
||||
});
|
||||
}
|
||||
@@ -408,6 +410,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
|
||||
assert.match(secondPayload.reply.content, /复用已有 runner/u);
|
||||
assert.ok(secondPayload.runnerTrace.events.some((event) => event.label === "agentrun:run:reused"));
|
||||
assert.ok(secondPayload.runnerTrace.events.some((event) => event.label === "agentrun:runner-job:reused"));
|
||||
assert.equal(secondPayload.runnerTrace.events.some((event) => String(event.text ?? event.message ?? "").includes("旧 command 尾部")), false);
|
||||
assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs").length, 1);
|
||||
assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs/run_hwlab_adapter/runner-jobs").length, 1);
|
||||
assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs/run_hwlab_adapter/commands").length, 2);
|
||||
|
||||
@@ -344,6 +344,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) {
|
||||
runnerTrace: traceStore.snapshot(traceId),
|
||||
updatedAt: new Date().toISOString()
|
||||
}, params);
|
||||
recordCodeAgentConversationFact(payload, executionOptions);
|
||||
results.set(traceId, payload);
|
||||
traceStore.append(traceId, {
|
||||
type: "result",
|
||||
@@ -478,6 +479,7 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti
|
||||
return;
|
||||
}
|
||||
if (synced.result && synced.result.status !== "running") {
|
||||
recordCodeAgentConversationFact(synced.result, options);
|
||||
await recordCodeAgentSessionOwner({ payload: synced.result, params: synced.result, options, status: synced.result.status === "completed" ? "active" : synced.result.status });
|
||||
sendJson(response, 200, compactCodeAgentChatResultPayload(synced.result, options));
|
||||
return;
|
||||
@@ -837,10 +839,41 @@ export async function handleCodeAgentCancelHttp(request, response, options) {
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
await recordCodeAgentSessionOwner({ payload, params: { ...params, traceId, ownerUserId: options.actor?.id, ownerRole: options.actor?.role }, options, status: "canceled" });
|
||||
recordCodeAgentConversationFact(payload, options);
|
||||
options.codeAgentChatResults?.set(traceId, annotateOwner(payload, { ownerUserId: options.actor?.id, ownerRole: options.actor?.role }));
|
||||
sendJson(response, 200, payload);
|
||||
}
|
||||
|
||||
function recordCodeAgentConversationFact(payload = {}, options = {}) {
|
||||
const registry = options.sessionRegistry;
|
||||
const conversationId = safeConversationId(payload.conversationId);
|
||||
if (!conversationId || typeof registry?.recordFact !== "function") return null;
|
||||
try {
|
||||
return registry.recordFact(conversationId, {
|
||||
kind: payload.status === "completed" ? "runner_turn" : "runner_turn_terminal",
|
||||
conversationId,
|
||||
sessionId: payload.sessionId,
|
||||
traceId: payload.traceId,
|
||||
provider: payload.provider,
|
||||
backend: payload.backend,
|
||||
workspace: payload.workspace,
|
||||
sandbox: payload.sandbox,
|
||||
session: payload.session,
|
||||
sessionMode: payload.sessionMode,
|
||||
sessionReuse: payload.sessionReuse,
|
||||
implementationType: payload.implementationType,
|
||||
runner: payload.runner,
|
||||
runnerTrace: payload.runnerTrace,
|
||||
partialContext: payload.partialContext ?? payload.runnerTrace?.partialContext,
|
||||
capabilityLevel: payload.capabilityLevel,
|
||||
toolCalls: payload.toolCalls,
|
||||
skills: payload.skills
|
||||
}, { now: options.now });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options = {}, status = "active" } = {}) {
|
||||
const ownerUserId = options.actor?.id ?? params.ownerUserId;
|
||||
if (!ownerUserId || !options.accessController?.recordAgentSessionOwner) return null;
|
||||
|
||||
@@ -102,11 +102,11 @@ async function submitAgentMessage(value, options = {}) {
|
||||
const traceId = options.traceId ?? nextProtocolId("trc");
|
||||
const activeConversationId = options.conversationId ?? state.conversationId ?? nextProtocolId("cnv");
|
||||
const requestedSessionId = options.sessionId === undefined ? sessionIdForNextRequest() : options.sessionId;
|
||||
const requestedThreadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId;
|
||||
const threadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId;
|
||||
const pendingContinuity = pendingSessionContinuity({
|
||||
conversationId: activeConversationId,
|
||||
sessionId: requestedSessionId,
|
||||
threadId: requestedThreadId,
|
||||
threadId,
|
||||
retryOf: options.retryOf
|
||||
});
|
||||
state.conversationId = activeConversationId;
|
||||
@@ -119,7 +119,7 @@ async function submitAgentMessage(value, options = {}) {
|
||||
traceId,
|
||||
conversationId: activeConversationId,
|
||||
sessionId: requestedSessionId,
|
||||
threadId: requestedThreadId,
|
||||
threadId,
|
||||
retryOf: options.retryOf ?? null,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
@@ -132,7 +132,7 @@ async function submitAgentMessage(value, options = {}) {
|
||||
traceId,
|
||||
conversationId: activeConversationId,
|
||||
sessionId: requestedSessionId,
|
||||
threadId: requestedThreadId,
|
||||
threadId,
|
||||
sessionContinuity: pendingContinuity,
|
||||
retryInput: value,
|
||||
retryOf: options.retryOf ?? null,
|
||||
@@ -145,7 +145,7 @@ async function submitAgentMessage(value, options = {}) {
|
||||
traceId,
|
||||
conversationId: activeConversationId,
|
||||
sessionId: requestedSessionId,
|
||||
threadId: requestedThreadId,
|
||||
threadId,
|
||||
messageId: pendingMessage.id,
|
||||
input: value,
|
||||
lastActivityAt: Date.now(),
|
||||
@@ -165,14 +165,13 @@ async function submitAgentMessage(value, options = {}) {
|
||||
const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id);
|
||||
|
||||
try {
|
||||
const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, requestedThreadId);
|
||||
const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, threadId);
|
||||
stopTraceStream();
|
||||
if (state.canceledTraces.has(traceId)) return;
|
||||
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
|
||||
const updatedMessage = applyCodeAgentResultToMessage(pendingMessage.id, result, {
|
||||
baseMessage: pendingMessage,
|
||||
pendingContinuity,
|
||||
requestedThreadId,
|
||||
retryInput: value,
|
||||
retryOf: options.retryOf ?? null
|
||||
});
|
||||
@@ -200,7 +199,7 @@ async function submitAgentMessage(value, options = {}) {
|
||||
text: presentation.text,
|
||||
status: failedStatus,
|
||||
traceId: error.traceId || traceId,
|
||||
threadId: requestedThreadId,
|
||||
threadId,
|
||||
sessionContinuity: failedSessionContinuity(pendingContinuity, error),
|
||||
updatedAt: new Date().toISOString(),
|
||||
retryInput: value,
|
||||
@@ -292,7 +291,7 @@ function applyCodeAgentResultToMessage(messageId, result, options = {}) {
|
||||
traceId: result.traceId || current.traceId,
|
||||
conversationId: result.conversationId || result.sessionId || state.conversationId,
|
||||
sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId,
|
||||
threadId: resultThreadId || options.requestedThreadId || current.threadId || state.threadId,
|
||||
threadId: resultThreadId || current.threadId || state.threadId,
|
||||
sessionContinuity,
|
||||
messageId: result.messageId,
|
||||
provider: result.provider,
|
||||
@@ -782,9 +781,9 @@ function renderProbePending() {
|
||||
renderCodeAgentSummary();
|
||||
}
|
||||
|
||||
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest(), requestedThreadId = threadIdForNextRequest()) {
|
||||
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest(), threadIdForRequest = threadIdForNextRequest()) {
|
||||
const sessionId = requestedSessionId || undefined;
|
||||
const threadId = requestedThreadId || undefined;
|
||||
const threadId = threadIdForRequest || undefined;
|
||||
const response = await fetchJson("/v1/agent/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -20,7 +20,7 @@ test("server auth is authoritative when restoring the workbench", async () => {
|
||||
|
||||
assert.equal(session.authenticated, true);
|
||||
assert.equal(session.mode, "server");
|
||||
assert.deepEqual(session.user, { username: "server-admin" });
|
||||
assert.equal(session.user.username, "server-admin");
|
||||
assert.deepEqual(harness.fetchCalls.map((call) => call.path), ["/auth/session"]);
|
||||
assert.deepEqual(harness.fetchCalls.map((call) => call.method), ["GET"]);
|
||||
assert.equal(harness.loginShell.hidden, true);
|
||||
|
||||
Reference in New Issue
Block a user