From 0d4f5ef0986f8947b82d7b1c9947bcb66fdff9da Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 3 Jun 2026 00:28:40 +0800 Subject: [PATCH] fix: preserve code agent sessions after turn failures --- docs/reference/spec-v02-hwlab-cloud-api.md | 1 + docs/reference/spec-v02-hwlab-cloud-web.md | 1 + internal/cloud/access-control.ts | 1 + internal/cloud/code-agent-agentrun-adapter.ts | 12 +++-- internal/cloud/code-agent-chat.ts | 10 ++-- .../cloud/code-agent-session-lifecycle.ts | 8 +-- .../cloud/code-agent-session-registry.test.ts | 49 ++++++++----------- internal/cloud/code-agent-session-registry.ts | 2 +- internal/cloud/server-agent-chat.test.ts | 5 ++ internal/cloud/server-code-agent-http.ts | 9 +++- web/hwlab-cloud-web/app-conversation.ts | 12 ++++- web/hwlab-cloud-web/app-device-pod.ts | 4 +- web/hwlab-cloud-web/code-agent-status.test.ts | 6 +-- web/hwlab-cloud-web/code-agent-status.ts | 4 +- 14 files changed, 72 insertions(+), 52 deletions(-) diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index ae552f15..666eacbb 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -19,6 +19,7 @@ - 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 可读进展,必须以脱敏、限长的 conversation facts 写入 UI/trace/inspect 证据,供后续 `inspect`/`--from-trace` 可见性使用;这些 facts 不得作为下一轮模型上下文或 prompt 拼接来源。 - AgentRun completed 轮次续接必须依赖 Codex stdio 原生 session continuation。Cloud API 只把本轮原始 `message/prompt` 和标准 `conversationId/sessionId/threadId` 写入 AgentRun command payload 与 `SessionRef`;不得从请求、account workspace 或 account conversation 生成 `conversationContext`,不得把历史消息拼入 prompt,也不得把请求体里的 `conversationContext/messages` 当作模型上下文。历史 conversation facts 只用于 UI、inspect、trace 和 `--from-trace` 的可见性证据;收到 synthetic context 字段时只能记录 ignored trace 并剥离。`thread/resume` 失败时按 AgentRun `thread-resume-failed` 终止本轮。 +- Code Agent 不允许存在 turn/session/conversation 总时长 timeout;只允许无新 app-server 响应、无 notification、无 assistant/tool/event activity 的 idle timeout。AgentRun command 失败、provider 失败或 idle timeout 只终结当前 command,不能把同一 conversation/session/thread 的后续 turn 截断;除显式取消、中断、过期或 thread-resume-failed stale 指针清理外,后续消息必须继续使用原 session/thread。 - Cloud API 通过 AgentRun v0.1 `runner-jobs.transientEnv` 传递本次 Code Agent turn 的短期上下文,例如 `HWLAB_RUNTIME_*`、`HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME` 和 `HWLAB_DEVICE_POD_API_KEY`。`transientEnv` 不设固定 8 项上限,新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value;`HWLAB_RUNTIME_API_URL` 必须指向当前 namespace 内的 `hwlab-cloud-api` Service,`HWLAB_RUNTIME_WEB_URL` 才指向 `hwlab-cloud-web`;`HWLAB_DEVICE_POD_API_KEY` 只能作为 assembled runner 内 `hwpod` 访问正式 device-pod 的统一授权,必须标记 sensitive,并继续禁止承载 GitHub token、provider key、长期 SSH key 或其他可复用 credential;文档、日志和 trace 只允许保留脱敏后的 name、来源或摘要,不打印 Secret 值。 - 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。 - `hwlab-code-agent-workspace` PVC 挂载到 `/workspace/hwlab`,用于长会话 workspace;它是 cloud-api 运行资源,不是独立用户入口。 diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index d4bb8dca..02edc3e4 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -16,6 +16,7 @@ - 同一 conversation/session 的后续用户消息必须在 AgentRun runner reuse window 有效时复用已存在的 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。 - Cloud Web 提交 Code Agent turn 时只发送当前用户消息、共享 workspace 的 `conversationId/sessionId/threadId`、workspace revision 和必要运行元数据;不得发送 `conversationContext/messages`,也不得把浏览器历史拼入 prompt。历史消息只用于本地 UI 展示和 trace/inspect 可见性,不能替代 AgentRun/Codex stdio 原生 `thread/resume`。 +- Workbench 不允许把 Code Agent 长任务总耗时当成失败条件;只能在无新 trace/event/activity 的 idle timeout 后显示超时。AgentRun command/provider/backend 失败后,当前消息可显示 failed/blocker,但 conversation/session/thread 仍是后续 turn 的默认 continuation,不能要求用户重新建 session;显式取消、中断、过期和 thread-resume-failed stale 指针清理除外。 - 同一 AgentRun run 复用多条 command 时,Web trace 展示只显示当前 command 归属事件和必要 run 级状态;旧 command 的 assistant/tool/terminal 尾部不能堆到新 command 末尾。取消轮次的可读进展必须作为脱敏 conversation facts 进入 UI/trace/inspect 证据,而不是靠旧 trace 尾部串线让后续轮次“碰巧看到”;这些 facts 不得作为下一轮模型上下文或 prompt 拼接来源。 ## 内部架构 diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 59899840..ee0be4e6 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -1854,6 +1854,7 @@ function terminalWorkbenchSessionStatus(result = {}) { const status = textOr(result.status ?? result.agentRun?.terminalStatus, "").toLowerCase(); if (status === "completed") return "idle"; if (status === "cancelled") return "canceled"; + if (status === "failed" && result.agentRun && result.reuseEligible !== false && result.agentRun.reuseEligible !== false) return "idle"; return status || "idle"; } function canActorReadWorkspaceTrace(result = {}, actor = null) { diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 924e3ece..12353dc8 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -907,13 +907,14 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) valuesPrinted: false }); const partialContext = partialAgentRunContext(runnerTrace); + const recoverableFailure = !canceled; return { ...base, status: canceled ? "canceled" : "failed", canceled, updatedAt: now, - session: agentRunSessionSummary(base, canceled ? "canceled" : "failed"), - sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true), + session: agentRunSessionSummary(base, canceled ? "canceled" : "idle"), + sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true, { status: recoverableFailure ? "available-after-failed-command" : undefined }), runner: agentRunRunnerSummary(base.agentRun), runnerTrace: partialContext ? { ...runnerTrace, partialContext } : runnerTrace, toolCalls: agentRunToolCalls(result, canceled ? "canceled" : "failed"), @@ -940,7 +941,8 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) route: "/v1/agent/chat", toolName: "agentrun.manual-dispatch" }, - agentRun: { ...base.agentRun, terminalStatus, completed: false, providerTrace, valuesPrinted: false }, + agentRun: { ...base.agentRun, terminalStatus, completed: false, reuseEligible: recoverableFailure, providerTrace, valuesPrinted: false }, + reuseEligible: recoverableFailure, ...(partialContext ? { partialContext } : {}), valuesPrinted: false }; @@ -1318,13 +1320,13 @@ function agentRunSessionSummary(base, status) { }; } -function agentRunSessionReuseSummary(base, reused) { +function agentRunSessionReuseSummary(base, reused, options = {}) { 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", + status: options.status ?? (reused ? "reused" : "new"), runnerReused: Boolean(reused), valuesRedacted: true }; diff --git a/internal/cloud/code-agent-chat.ts b/internal/cloud/code-agent-chat.ts index ae185f3b..306f4540 100644 --- a/internal/cloud/code-agent-chat.ts +++ b/internal/cloud/code-agent-chat.ts @@ -1324,9 +1324,9 @@ function errorTaxonomy(code, error = {}) { }, session_failed: { layer: "session", - category: "session_blocked", + category: "runner_blocked", retryable: true, - userMessage: "Code Agent session 已失败,可重新发送建立新的 session。" + userMessage: "Code Agent 当前 turn 已失败,session/thread 已保留,可继续发送下一条消息。" }, session_interrupted: { layer: "session", @@ -1344,13 +1344,13 @@ function errorTaxonomy(code, error = {}) { layer: "session", category: "timeout", retryable: true, - userMessage: "Code Agent session 已超时,输入和 trace 已保留,可重试上一条消息。" + userMessage: "Code Agent 当前 turn 已超时,输入、trace、session/thread 已保留,可继续发送下一条消息。" }, session_error: { layer: "session", - category: "session_blocked", + category: "runner_blocked", retryable: true, - userMessage: "Code Agent session 返回错误,输入和 trace 已保留,可重试上一条消息。" + userMessage: "Code Agent 当前 turn 返回错误,输入、trace、session/thread 已保留,可继续发送下一条消息。" }, runner_unavailable: { layer: "runner", diff --git a/internal/cloud/code-agent-session-lifecycle.ts b/internal/cloud/code-agent-session-lifecycle.ts index 6d54e5ed..de4d25c8 100644 --- a/internal/cloud/code-agent-session-lifecycle.ts +++ b/internal/cloud/code-agent-session-lifecycle.ts @@ -18,7 +18,7 @@ export const CODE_AGENT_SESSION_STATUS_ALIASES = Object.freeze({ }); const ACTIVE_LIFECYCLE_STATUSES = new Set(["creating", "ready", "busy", "idle"]); -const TERMINAL_LIFECYCLE_STATUSES = new Set(["interrupted", "expired", "failed"]); +const TERMINAL_LIFECYCLE_STATUSES = new Set(["interrupted", "expired"]); export function normalizeCodeAgentSessionLifecycleStatus(value, { fallback = null } = {}) { const text = String(value ?? "").trim().toLowerCase(); @@ -75,7 +75,7 @@ export function codeAgentSessionLifecycleSummary(input = {}) { (gateBlocked && !concreteLifecycleBlock); if ((fallbackUsed || degraded) && !hasSession) status = "failed"; const requiresNewSession = status ? TERMINAL_LIFECYCLE_STATUSES.has(status) : degraded || fallbackUsed; - const reusable = status ? ACTIVE_LIFECYCLE_STATUSES.has(status) && status !== "creating" && degraded !== true : false; + const reusable = status ? ((ACTIVE_LIFECYCLE_STATUSES.has(status) && status !== "creating") || status === "failed") && degraded !== true : false; const newSession = reused === false || (hasSession && !reused && !requiresNewSession); const reasonCode = firstText( input.degradationReasonCode, @@ -171,8 +171,8 @@ function lifecyclePresentation({ status, rawStatus, reused, newSession, degraded if (status === "failed") { return { label: "会话失败", - action: "new_session", - userMessage: "当前 Code Agent session 已失败,需重新发送建立新会话。" + action: "continue", + userMessage: "当前 Code Agent turn 已失败;session/thread 已保留,可继续发送下一条消息。" }; } if (status === "interrupted") { diff --git a/internal/cloud/code-agent-session-registry.test.ts b/internal/cloud/code-agent-session-registry.test.ts index 514208bf..b3ce8b1a 100644 --- a/internal/cloud/code-agent-session-registry.test.ts +++ b/internal/cloud/code-agent-session-registry.test.ts @@ -326,7 +326,7 @@ test("code agent session registry reports busy sessions as structured blocker", assert.equal(busy.session.currentTraceId, "trc_busy_1"); }); -test("code agent session registry blocks failed and interrupted sessions without reusing them", () => { +test("code agent session registry resumes failed sessions and still blocks interrupted sessions", () => { const failedRegistry = createCodeAgentSessionRegistry({ idFactory: () => "ses_failed" }); @@ -348,12 +348,14 @@ test("code agent session registry blocks failed and interrupted sessions without traceId: "trc_failed_2", now: "2026-05-23T00:00:01.000Z" }); - assert.equal(failed.ok, false); - assert.equal(failed.code, "session_failed"); - assert.equal(failed.session.status, "failed"); - assert.equal(failed.session.lifecycleStatus, "failed"); - assert.match(failed.session.lifecycle.userMessage, /需重新发送建立新会话/u); - assert.equal(failed.blocker.sourceIssue, "pikasTech/HWLAB#317"); + assert.equal(failed.ok, true); + assert.equal(failed.reused, true); + assert.equal(failed.session.sessionId, "ses_failed"); + assert.equal(failed.session.status, "busy"); + assert.equal(failed.session.lifecycleStatus, "busy"); + assert.equal(failed.session.currentTraceId, "trc_failed_2"); + assert.equal(failed.session.lastTraceId, "trc_failed_2"); + assert.equal(failed.session.turn, 2); const interruptedRegistry = createCodeAgentSessionRegistry({ idFactory: () => "ses_interrupted" @@ -493,7 +495,7 @@ test("stale read-only conversation facts do not mask Codex stdio blockers", asyn assert.equal(payload.provider === "openai-responses", false); }); -test("expired, busy, and failed session states are surfaced as structured blockers", async () => { +test("expired and busy sessions are blockers while failed sessions can resume", async () => { const expiredRegistry = createCodeAgentSessionRegistry({ idleTimeoutMs: 10, idFactory: () => "ses_expired_chat" @@ -646,25 +648,16 @@ test("expired, busy, and failed session states are surfaced as structured blocke traceId: "trc_failed_request", now: "2026-05-23T00:04:01.000Z" }); - const failed = codeAgentBlockedPayloadFromAcquire(failedAcquire, { - conversationId: "cnv_failed_chat", - traceId: "trc_failed_request", - conversationFacts: failedRegistry.getConversationFacts("cnv_failed_chat") - }); - assert.equal(failed.status, "failed"); - assert.equal(failed.error.code, "session_failed"); - assert.equal(failed.error.layer, "session"); - assert.equal(failed.error.retryable, true); - assert.match(failed.error.userMessage, /session 已失败/u); - assert.equal(failed.error.blocker.code, "session_failed"); - assert.equal(failed.session.status, "failed"); - assert.equal(failed.sessionLifecycleStatus, "failed"); - assert.equal(failed.sessionSummary.requiresNewSession, true); - assert.match(failed.sessionSummary.userMessage, /需重新发送建立新会话/u); - assert.equal(failed.capabilityLevel, "blocked"); - assert.equal(failed.conversationFacts.conversationId, "cnv_failed_chat"); - assert.equal(failed.conversationFacts.sessionId, "ses_failed_chat"); - assert.equal(failed.conversationFacts.recentToolCalls[0].name, "pwd"); + assert.equal(failedAcquire.ok, true); + assert.equal(failedAcquire.reused, true); + assert.equal(failedAcquire.session.sessionId, "ses_failed_chat"); + assert.equal(failedAcquire.session.status, "busy"); + assert.equal(failedAcquire.session.lifecycleStatus, "busy"); + assert.equal(failedAcquire.session.currentTraceId, "trc_failed_request"); + const failedFacts = failedRegistry.getConversationFacts("cnv_failed_chat"); + assert.equal(failedFacts.conversationId, "cnv_failed_chat"); + assert.equal(failedFacts.sessionId, "ses_failed_chat"); + assert.equal(failedFacts.recentToolCalls[0].name, "pwd"); }); test("OpenAI fallback hook is not used when Codex stdio is unavailable", async () => { @@ -1196,7 +1189,7 @@ function codeAgentBlockedPayloadFromAcquire(acquireResult, { conversationId, tra ? "Code Agent session 正在处理上一轮请求,请稍后重试。" : code === "session_expired" ? "Code Agent session 已过期,可重新发送建立新的 session。" - : "Code Agent session 已失败,可重新发送建立新的 session。"; + : "Code Agent 当前 turn 已失败,session/thread 已保留,可继续发送下一条消息。"; const sessionSummary = acquireResult.session.lifecycle; const blocker = { code, diff --git a/internal/cloud/code-agent-session-registry.ts b/internal/cloud/code-agent-session-registry.ts index b27b32d0..b91b48c3 100644 --- a/internal/cloud/code-agent-session-registry.ts +++ b/internal/cloud/code-agent-session-registry.ts @@ -84,7 +84,7 @@ export function createCodeAgentSessionRegistry(options = {}) { }); } - if (session && ["failed", "interrupted", "timeout", "error", "canceled"].includes(session.status)) { + if (session && ["interrupted", "canceled"].includes(session.status)) { session.updatedAt = timestamp; session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId; return blockedAcquire({ diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index cd081eb4..e909b031 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -644,6 +644,11 @@ test("cloud api AgentRun adapter exposes invalid tool-call attribution in result assert.match(payload.error.userMessage, /不是 HWLAB device-pod/u); assert.equal(payload.blocker.category, "provider_invalid_tool_call"); assert.match(payload.blocker.summary, /invalid function arguments json string/u); + assert.equal(payload.session.status, "idle"); + assert.equal(payload.sessionReuse.status, "available-after-failed-command"); + assert.equal(payload.sessionReuse.threadId, "thread_invalid_tool"); + assert.equal(payload.agentRun.reuseEligible, true); + assert.equal(payload.reuseEligible, true); } 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/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index f0de09c1..8a03c339 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -502,7 +502,7 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti } 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 }); + await recordCodeAgentSessionOwner({ payload: synced.result, params: synced.result, options, status: codeAgentOwnerStatusForResult(synced.result) }); sendJson(response, 200, compactCodeAgentChatResultPayload(synced.result, options)); return; } @@ -1057,6 +1057,13 @@ async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options } } +function codeAgentOwnerStatusForResult(result = {}) { + if (result?.status === "completed") return "active"; + if (result?.status === "canceled" || result?.status === "cancelled") return "canceled"; + if (result?.agentRun && result?.reuseEligible !== false && result?.agentRun?.reuseEligible !== false) return "active"; + return result?.status ?? "active"; +} + async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId, response } = {}) { const workspaceId = safeWorkspaceId(params.workspaceId); if (!workspaceId || !options.accessController?.store || !options.actor) return null; diff --git a/web/hwlab-cloud-web/app-conversation.ts b/web/hwlab-cloud-web/app-conversation.ts index 186f3ce1..c7fc2bc3 100644 --- a/web/hwlab-cloud-web/app-conversation.ts +++ b/web/hwlab-cloud-web/app-conversation.ts @@ -764,11 +764,19 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {}) }; } - if (["session_reuse_conflict", "session_failed", "session_interrupted", "session_canceled", "session_timeout", "session_error"].includes(code)) { + if (["session_failed", "session_timeout", "session_error"].includes(code)) { + return { + category: "runner_blocked", + title: "Code Agent 当前轮次失败", + text: `当前 Code Agent turn 失败、超时或返回错误;输入、sessionId 和 traceId 已保留,可继续发送下一条消息。${traceSuffix}` + }; + } + + if (["session_reuse_conflict", "session_interrupted", "session_canceled"].includes(code)) { return { category: "session_blocked", title: "Code Agent Session 受阻", - text: `当前 session 已过期、失败、中断或与 conversation 绑定不一致;输入已保留,可重新发送建立新的 Codex stdio session。${traceSuffix}` + text: `当前 session 已中断、取消或与 conversation 绑定不一致;输入已保留,可重新发送建立新的 Codex stdio session。${traceSuffix}` }; } diff --git a/web/hwlab-cloud-web/app-device-pod.ts b/web/hwlab-cloud-web/app-device-pod.ts index d04a06cb..67b4c154 100644 --- a/web/hwlab-cloud-web/app-device-pod.ts +++ b/web/hwlab-cloud-web/app-device-pod.ts @@ -522,12 +522,14 @@ function failedSessionContinuity(pending = null, error = null) { : code === "session_expired" ? "会话已过期" : ["session_failed", "session_error", "session_timeout"].includes(code) - ? "需新建会话" + ? "可继续会话" : "会话受阻"; const summary = code === "session_busy" ? "上一轮仍在处理;输入已保留,请等待当前请求完成或取消后重试。" : code === "session_expired" ? "当前 session 已过期;输入已保留,可重新发送建立新的 Code Agent session。" + : ["session_failed", "session_error", "session_timeout"].includes(code) + ? "当前 Code Agent turn 失败、超时或返回错误;session/thread 已保留,可继续发送下一条消息。" : `本次请求没有完成可复用会话确认;输入已保留。${error?.code ? ` code=${error.code}` : ""}`; return { ...base, diff --git a/web/hwlab-cloud-web/code-agent-status.test.ts b/web/hwlab-cloud-web/code-agent-status.test.ts index b3355af1..2e7a51e4 100644 --- a/web/hwlab-cloud-web/code-agent-status.test.ts +++ b/web/hwlab-cloud-web/code-agent-status.test.ts @@ -413,10 +413,10 @@ test("surfaces expired and failed Code Agent session lifecycle hints", () => { } } }); - assert.equal(failed.kind, "session-failed"); - assert.equal(failed.label, "会话失败"); + assert.equal(failed.kind, "turn-failed-session-continuable"); + assert.equal(failed.label, "当前轮次失败"); assert.equal(failed.sessionLifecycleStatus, "failed"); - assert.match(failed.sessionLifecycleHint, /需重新发送建立新会话/u); + assert.match(failed.sessionLifecycleHint, /session\/thread 已保留/u); }); test("missing Code Agent session evidence is shown as lifecycle degradation", () => { diff --git a/web/hwlab-cloud-web/code-agent-status.ts b/web/hwlab-cloud-web/code-agent-status.ts index 2fe2ad5b..2f0d338f 100644 --- a/web/hwlab-cloud-web/code-agent-status.ts +++ b/web/hwlab-cloud-web/code-agent-status.ts @@ -175,7 +175,7 @@ function classifyPayload(payload, blockers) { } if (lifecycle.status === "failed" && (payload.session?.status === "failed" || payload.sessionLifecycle?.status === "failed" || payload.sessionSummary?.status === "failed") && !isFallbackTextChatOnly(payload)) { - return status("session-failed", "会话失败", "blocked", "×", lifecycle.hint || "当前 Code Agent session 已失败,需重新发送建立新会话。"); + return status("turn-failed-session-continuable", "当前轮次失败", "warn", "!", lifecycle.hint || "当前 Code Agent turn 已失败;session/thread 已保留,可继续发送下一条消息。"); } if (lifecycle.status === "interrupted" && !["canceled", "timeout", "error"].includes(payload.status)) { @@ -453,7 +453,7 @@ function sessionLifecycleSummary(payload = {}) { return { status, label: "会话已过期", hint: "会话已过期,可重新发送建立新的 Code Agent session。" }; } if (status === "failed") { - return { status, label: "会话失败", hint: "当前 Code Agent session 已失败,需重新发送建立新会话。" }; + return { status, label: "会话失败", hint: "当前 Code Agent turn 已失败;session/thread 已保留,可继续发送下一条消息。" }; } if (status === "interrupted") { return { status, label: "会话已中断", hint: "当前 Code Agent session 已中断;输入和 trace 已保留,可重试或重新发送。" };