diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 9131e1c6..ebe9d546 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -27,6 +27,10 @@ import { DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS, createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs"; +import { + codeAgentSessionLifecycleSummary, + decorateCodeAgentSession +} from "./code-agent-session-lifecycle.mjs"; import { HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, HWLAB_M3_IO_CAPABILITY_LEVELS, @@ -371,7 +375,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { } else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked", "codex_stdio_blocked", "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response", "codex_stdio_command_probe_failed", "external_network_blocked", "network_tool_unavailable", "network_timeout"].includes(error.code)) { payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options); } - return payload; + return decorateChatSessionLifecycle(payload); } } @@ -384,7 +388,7 @@ function completedRunnerPayload({ base, runnerResult, messageId, now, sessionReg runner: runnerResult.runner, capabilityLevel: runnerResult.capabilityLevel }); - const payload = finalizeCodeAgentChatPayload({ + const payload = finalizeCodeAgentChatPayload(decorateChatSessionLifecycle({ ...base, sessionId: runnerResult.session?.sessionId ?? base.sessionId, status: "completed", @@ -417,8 +421,8 @@ function completedRunnerPayload({ base, runnerResult, messageId, now, sessionReg ...(blocker ? { blocker, blockers: [blocker, ...(runnerResult.blockers ?? [])].filter(Boolean) } : {}), usage: null, providerTrace: runnerResult.providerTrace - }); - return attachConversationFacts(payload, sessionRegistry, { now }); + })); + return decorateChatSessionLifecycle(attachConversationFacts(payload, sessionRegistry, { now })); } export function validateCodeAgentChatSchema(payload) { @@ -538,6 +542,62 @@ function attachConversationFacts(payload, sessionRegistry, { now } = {}) { }; } +function decorateChatSessionLifecycle(payload) { + if (!payload || typeof payload !== "object") return payload; + const decoratedSession = payload.session && typeof payload.session === "object" + ? decorateCodeAgentSession(payload.session, { + reused: payload.sessionReuse?.reused ?? payload.session?.reused, + runnerTrace: payload.runnerTrace, + sessionReuse: payload.sessionReuse, + status: payload.status, + provider: payload.provider, + runner: payload.runner, + capabilityLevel: payload.capabilityLevel, + implementationType: payload.implementationType, + longLivedSessionGate: payload.longLivedSessionGate, + error: payload.error, + blocker: payload.blocker + }) + : payload.session; + const runnerTrace = payload.runnerTrace && typeof payload.runnerTrace === "object" + ? { + ...payload.runnerTrace, + sessionLifecycleStatus: payload.runnerTrace.sessionLifecycleStatus ?? + codeAgentSessionLifecycleSummary({ + session: decoratedSession, + sessionStatus: payload.runnerTrace.sessionStatus, + status: payload.status, + provider: payload.provider, + runner: payload.runner, + capabilityLevel: payload.capabilityLevel, + implementationType: payload.implementationType, + error: payload.error, + blocker: payload.blocker + }).status + } + : payload.runnerTrace; + const summary = codeAgentSessionLifecycleSummary({ + ...payload, + session: decoratedSession, + runnerTrace + }); + return { + ...payload, + session: decoratedSession, + runnerTrace, + sessionStatus: decoratedSession?.status ?? payload.sessionStatus ?? runnerTrace?.sessionStatus ?? null, + sessionLifecycleStatus: summary.status, + sessionLifecycle: summary, + sessionSummary: summary, + sessionReused: summary.reused, + isNewSession: summary.newSession, + fallbackUsed: summary.fallback, + degraded: summary.degraded, + degradationReason: summary.reason, + degradationReasonCode: summary.reasonCode + }; +} + export async function describeCodeAgentAvailability(env = process.env, options = {}) { const providerPlan = resolveProviderPlan(env, options); const providerContract = inspectCodeAgentProviderEnv(env); @@ -2899,7 +2959,7 @@ export function createCodeAgentErrorPayload({ const error = new Error(message); Object.assign(error, { code, reason, layer, retryable, provider, model, backend, capabilityLevel, route, toolName }); const normalized = normalizeChatError(error, { traceId, provider, model, backend, capabilityLevel, route, toolName }); - return { + return decorateChatSessionLifecycle({ conversationId, sessionId, messageId, @@ -2914,7 +2974,7 @@ export function createCodeAgentErrorPayload({ error: normalized, blocker: normalized.blocker, blockers: normalized.blockers - }; + }); } function normalizeChatError(error, context = {}) { diff --git a/internal/cloud/code-agent-session-lifecycle.mjs b/internal/cloud/code-agent-session-lifecycle.mjs new file mode 100644 index 00000000..6d54e5ed --- /dev/null +++ b/internal/cloud/code-agent-session-lifecycle.mjs @@ -0,0 +1,248 @@ +export const CODE_AGENT_SESSION_LIFECYCLE_STATUSES = Object.freeze([ + "creating", + "ready", + "busy", + "idle", + "interrupted", + "expired", + "failed" +]); + +export const CODE_AGENT_SESSION_STATUS_ALIASES = Object.freeze({ + canceled: "interrupted", + cancelled: "interrupted", + timeout: "failed", + error: "failed", + blocked: "failed", + degraded: "failed" +}); + +const ACTIVE_LIFECYCLE_STATUSES = new Set(["creating", "ready", "busy", "idle"]); +const TERMINAL_LIFECYCLE_STATUSES = new Set(["interrupted", "expired", "failed"]); + +export function normalizeCodeAgentSessionLifecycleStatus(value, { fallback = null } = {}) { + const text = String(value ?? "").trim().toLowerCase(); + if (!text) return fallback; + if (CODE_AGENT_SESSION_LIFECYCLE_STATUSES.includes(text)) return text; + if (CODE_AGENT_SESSION_STATUS_ALIASES[text]) return CODE_AGENT_SESSION_STATUS_ALIASES[text]; + if (text === "running" || text === "in_flight" || text === "in-flight") return "busy"; + if (text === "completed" || text === "available" || text === "feasible" || text === "pass") return "idle"; + return fallback; +} + +export function codeAgentSessionLifecycleSummary(input = {}) { + const session = objectOrNull(input.session); + const runnerTrace = objectOrNull(input.runnerTrace); + const sessionReuse = objectOrNull(input.sessionReuse); + const sessionSummary = objectOrNull(input.sessionSummary); + const evidenceSessionId = firstText( + session?.sessionId, + sessionReuse?.sessionId, + runnerTrace?.sessionId + ); + const topLevelSessionId = firstText(input.sessionId); + const conversationId = firstText(input.conversationId); + const hasSession = Boolean(evidenceSessionId || (topLevelSessionId && topLevelSessionId !== conversationId)); + const rawStatus = firstText( + input.lifecycleStatus, + input.sessionLifecycleStatus, + session?.lifecycleStatus, + session?.lifecycle?.status, + sessionSummary?.status, + session?.status, + input.sessionStatus, + runnerTrace?.sessionLifecycleStatus, + runnerTrace?.sessionStatus, + sessionReuse?.status, + input.status + ); + const fallback = fallbackLifecycleStatus(input); + let status = normalizeCodeAgentSessionLifecycleStatus(rawStatus, { fallback }); + const reused = booleanOrNull(input.reused) ?? + booleanOrNull(sessionSummary?.reused) ?? + booleanOrNull(sessionReuse?.reused) ?? + booleanOrNull(session?.reused) ?? + booleanOrNull(runnerTrace?.sessionReused); + const fallbackUsed = isFallbackPayload(input); + const concreteLifecycleBlock = ["busy", "expired", "failed", "interrupted"].includes(status) && hasSession; + const gateBlocked = objectOrNull(input.longLivedSessionGate)?.status === "blocked"; + const evidenceMissing = objectOrNull(input.blocker)?.code === "code_agent_session_evidence_missing" || + objectOrNull(input.error)?.code === "code_agent_session_evidence_missing"; + const degraded = fallbackUsed || + input.unsupported === true || + input.degraded === true || + evidenceMissing || + (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 newSession = reused === false || (hasSession && !reused && !requiresNewSession); + const reasonCode = firstText( + input.degradationReasonCode, + objectOrNull(input.error)?.code, + objectOrNull(input.blocker)?.code, + objectOrNull(input.longLivedSessionGate)?.blocker, + fallbackUsed ? "text_chat_only_fallback" : null + ); + const reason = firstText( + input.degradationReason, + objectOrNull(input.error)?.userMessage, + objectOrNull(input.blocker)?.userMessage, + objectOrNull(input.blocker)?.summary, + objectOrNull(input.error)?.message, + reasonCode + ); + const presentation = lifecyclePresentation({ + status, + rawStatus, + reused, + newSession, + degraded, + fallbackUsed, + reason + }); + return { + status, + rawStatus: rawStatus ?? null, + label: presentation.label, + action: presentation.action, + reusable, + reused, + newSession, + busy: status === "busy", + requiresNewSession, + degraded, + fallback: fallbackUsed, + unsupported: input.unsupported === true, + reasonCode: reasonCode ?? null, + reason: reason ?? null, + userMessage: presentation.userMessage + }; +} + +export function decorateCodeAgentSession(session, context = {}) { + if (!session || typeof session !== "object") return session; + const lifecycle = codeAgentSessionLifecycleSummary({ ...context, session }); + return { + ...session, + lifecycleStatus: lifecycle.status, + lifecycle + }; +} + +function fallbackLifecycleStatus(input) { + if (isFallbackPayload(input) || input.degraded === true || input.unsupported === true) return "failed"; + const status = String(input.status ?? "").toLowerCase(); + if (status === "running") return "busy"; + if (["failed", "timeout", "error"].includes(status)) return "failed"; + if (status === "canceled") return "interrupted"; + return null; +} + +function lifecyclePresentation({ status, rawStatus, reused, newSession, degraded, fallbackUsed, reason }) { + if (fallbackUsed) { + return { + label: "会话降级", + action: "degraded", + userMessage: "当前响应来自 fallback/text-only/read-only 降级路径,不会按完整 Code Agent 长会话成功展示。" + }; + } + if (degraded) { + return { + label: "会话降级", + action: "degraded", + userMessage: `当前会话证据不完整或能力降级;输入已保留,可重新发送建立新会话。${reason ? ` 原因:${reason}` : ""}` + }; + } + if (status === "busy") { + return { + label: "会话忙", + action: "wait", + userMessage: "会话忙/请等待:上一轮仍在处理,稍后重试或取消当前请求。" + }; + } + if (status === "expired") { + return { + label: "会话已过期", + action: "new_session", + userMessage: "会话已过期,可重新发送建立新的 Code Agent session。" + }; + } + if (status === "failed") { + return { + label: "会话失败", + action: "new_session", + userMessage: "当前 Code Agent session 已失败,需重新发送建立新会话。" + }; + } + if (status === "interrupted") { + return { + label: rawStatus === "canceled" ? "会话已取消" : "会话已中断", + action: "retry", + userMessage: "当前 Code Agent session 已中断;输入和 trace 已保留,可重试或重新发送。" + }; + } + if (status === "creating") { + return { + label: "正在建立会话", + action: "wait", + userMessage: "正在建立 Code Agent session,请等待后端返回 session/thread。" + }; + } + if (status === "ready" || status === "idle") { + if (reused === true) { + return { + label: "继续当前会话", + action: "continue", + userMessage: "可继续当前会话:后端已确认复用现有 conversation/session/thread。" + }; + } + if (newSession === true) { + return { + label: "新会话已建立", + action: "continue", + userMessage: "新会话已建立:后端返回了可复用的 session/thread。" + }; + } + return { + label: "会话可用", + action: "continue", + userMessage: "Code Agent session 处于可继续状态。" + }; + } + return { + label: "会话未确认", + action: "observe", + userMessage: "尚未观测到可复用的 Code Agent session 状态。" + }; +} + +function isFallbackPayload(input) { + const runner = objectOrNull(input.runner); + const error = objectOrNull(input.error); + const blocker = objectOrNull(input.blocker ?? error?.blocker); + return input.fallback === true || + input.fallbackUsed === true || + input.capabilityLevel === "text-chat-only" || + input.provider === "openai-responses" || + runner?.kind === "openai-responses-fallback" || + input.implementationType === "openai-responses-fallback" || + blocker?.code === "text_chat_only_fallback" || + error?.code === "text_chat_only_fallback"; +} + +function objectOrNull(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : null; +} + +function firstText(...values) { + for (const value of values) { + const text = String(value ?? "").trim(); + if (text) return text; + } + return null; +} + +function booleanOrNull(value) { + return typeof value === "boolean" ? value : null; +} diff --git a/internal/cloud/code-agent-session-registry.mjs b/internal/cloud/code-agent-session-registry.mjs index 5fcea4ea..905a73f4 100644 --- a/internal/cloud/code-agent-session-registry.mjs +++ b/internal/cloud/code-agent-session-registry.mjs @@ -1,5 +1,12 @@ import { randomUUID } from "node:crypto"; +import { + CODE_AGENT_SESSION_LIFECYCLE_STATUSES, + CODE_AGENT_SESSION_STATUS_ALIASES, + codeAgentSessionLifecycleSummary, + decorateCodeAgentSession +} from "./code-agent-session-lifecycle.mjs"; + export const DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; export const DEFAULT_CODE_AGENT_MAX_SESSIONS = 200; export const DEFAULT_CODE_AGENT_MAX_CONVERSATION_FACTS = 12; @@ -235,6 +242,8 @@ export function createCodeAgentSessionRegistry(options = {}) { maxConversationFacts, idleTimeoutMs, statuses: [...CODE_AGENT_SESSION_STATUSES], + lifecycleStatuses: [...CODE_AGENT_SESSION_LIFECYCLE_STATUSES], + statusAliases: { ...CODE_AGENT_SESSION_STATUS_ALIASES }, sessionMode: "controlled-readonly-session-registry", recentSessions: [...sessions.values()] .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))) @@ -309,7 +318,7 @@ function blockedAcquire({ code, summary, session, timestamp, traceId }) { function publicSession(session, { conversationId = null, reused = null } = {}) { const conversationIds = [...session.conversationIds]; - return { + return decorateCodeAgentSession({ sessionId: session.sessionId, conversationId: conversationId ?? conversationIds[0] ?? null, conversationIds, @@ -337,7 +346,7 @@ function publicSession(session, { conversationId = null, reused = null } = {}) { secretMaterialStored: false, valuesRedacted: true, ...(session.statusReason ? { statusReason: session.statusReason } : {}) - }; + }, { reused }); } function publicConversationFacts({ conversationId, session, facts, maxFactToolCalls, maxConversationFacts }) { @@ -357,6 +366,8 @@ function publicConversationFacts({ conversationId, session, facts, maxFactToolCa conversationId, sessionId: session?.sessionId ?? latestSessionFact?.sessionId ?? latest?.sessionId ?? null, sessionStatus: session?.status ?? latestSessionFact?.sessionStatus ?? latest?.sessionStatus ?? null, + sessionLifecycleStatus: session?.lifecycleStatus ?? + normalizeFactLifecycleStatus(latestSessionFact?.sessionLifecycleStatus ?? latest?.sessionLifecycleStatus ?? latestSessionFact?.sessionStatus ?? latest?.sessionStatus), sessionMode: session?.sessionMode ?? latestSessionFact?.sessionMode ?? latest?.sessionMode ?? null, capabilityLevel: latestOperational?.capabilityLevel ?? session?.capabilityLevel ?? latest?.capabilityLevel ?? null, runnerKind: latestOperational?.runnerKind ?? session?.runnerKind ?? latest?.runnerKind ?? null, @@ -389,6 +400,13 @@ function sanitizeConversationFact(fact, { conversationId, timestamp, maxFactTool conversationId, sessionId: safeFactText(session?.sessionId ?? fact.sessionId ?? runner?.sessionId, 120), sessionStatus: safeFactText(session?.status ?? fact.sessionStatus, 40), + sessionLifecycleStatus: safeFactText( + session?.lifecycleStatus ?? + runnerTrace?.sessionLifecycleStatus ?? + fact.sessionLifecycleStatus ?? + normalizeFactLifecycleStatus(session?.status ?? fact.sessionStatus), + 40 + ), sessionMode: safeFactText(fact.sessionMode ?? session?.sessionMode ?? runner?.sessionMode ?? runnerTrace?.sessionMode, 120), sessionReused: fact.sessionReuse?.reused === true || session?.reused === true || runner?.sessionReused === true, turn: numberOrNull(session?.turn ?? fact.sessionReuse?.turn ?? runnerTrace?.turn ?? runner?.turn), @@ -462,6 +480,8 @@ function sanitizeRunnerTrace(runnerTrace) { runnerKind: safeFactText(runnerTrace.runnerKind, 120), sessionMode: safeFactText(runnerTrace.sessionMode, 120), sessionId: safeFactText(runnerTrace.sessionId, 120), + sessionStatus: safeFactText(runnerTrace.sessionStatus, 80), + sessionLifecycleStatus: safeFactText(runnerTrace.sessionLifecycleStatus ?? normalizeFactLifecycleStatus(runnerTrace.sessionStatus), 80), turn: numberOrNull(runnerTrace.turn), events: Array.isArray(runnerTrace.events) ? runnerTrace.events.map((event) => safeFactText(event, 120)).filter(Boolean).slice(-8) @@ -473,6 +493,10 @@ function sanitizeRunnerTrace(runnerTrace) { }; } +function normalizeFactLifecycleStatus(status) { + return codeAgentSessionLifecycleSummary({ sessionStatus: status }).status; +} + function conversationFactsSummary({ facts, session, latestSkills, latestWorkspace, traceIds, recentToolCalls }) { const skillNames = latestSkills?.names?.slice(0, 5) ?? []; const toolNames = recentToolCalls.map((toolCall) => [toolCall.name, toolCall.status].filter(Boolean).join(":")).filter(Boolean).slice(-5); diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 28be92e0..2c7bf5a9 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -13,6 +13,7 @@ import { createCodexStdioSessionManager } from "./codex-stdio-session.mjs"; import { + classifyCodeAgentChatReadiness, classifyCodexRunnerCapability } from "../../scripts/src/code-agent-response-contract.mjs"; import { @@ -40,6 +41,8 @@ test("code agent session registry creates, reuses, and expires sessions without assert.equal(first.reused, false); assert.equal(first.session.sessionId, "ses_test-registry"); assert.equal(first.session.status, "busy"); + assert.equal(first.session.lifecycleStatus, "busy"); + assert.equal(first.session.lifecycle.busy, true); assert.equal(first.session.workspace, "/workspace/hwlab"); assert.equal(first.session.sandbox, "read-only"); assert.equal(first.session.runnerKind, "hwlab-readonly-runner"); @@ -62,6 +65,8 @@ test("code agent session registry creates, reuses, and expires sessions without assert.equal(describedCreating.recentSessions[0].sessionMode, "controlled-readonly-session-registry"); assert.equal(describedCreating.recentSessions[0].workspace, "/workspace/hwlab"); assert.equal(describedCreating.recentSessions[0].secretMaterialStored, false); + assert.deepEqual(describedCreating.lifecycleStatuses, ["creating", "ready", "busy", "idle", "interrupted", "expired", "failed"]); + assert.equal(describedCreating.statusAliases.canceled, "interrupted"); registry.release(first.session.sessionId, { conversationId: "cnv_registry", traceId: "trc_registry_1", @@ -83,6 +88,7 @@ test("code agent session registry creates, reuses, and expires sessions without assert.equal(second.session.sessionId, "ses_test-registry"); assert.equal(second.session.turn, 2); assert.equal(second.session.lastTraceId, "trc_registry_2"); + assert.equal(second.session.lifecycleStatus, "busy"); registry.release(second.session.sessionId, { conversationId: "cnv_registry", traceId: "trc_registry_2", @@ -102,6 +108,8 @@ test("code agent session registry creates, reuses, and expires sessions without assert.equal(expired.ok, false); assert.equal(expired.code, "session_expired"); assert.equal(expired.session.status, "expired"); + assert.equal(expired.session.lifecycleStatus, "expired"); + assert.match(expired.session.lifecycle.userMessage, /会话已过期/u); assert.equal(expired.blocker.sourceIssue, "pikasTech/HWLAB#317"); }); @@ -190,6 +198,7 @@ test("code agent session registry stores bounded non-sensitive conversation fact const facts = registry.getConversationFacts("cnv_fact_registry"); assert.equal(facts.conversationId, "cnv_fact_registry"); assert.equal(facts.sessionId, "ses_fact_registry"); + assert.equal(facts.sessionLifecycleStatus, "idle"); assert.equal(facts.workspace, "/workspace/hwlab"); assert.equal(facts.turnCount, 2); assert.deepEqual(facts.traceIds, ["trc_fact_registry_2", "trc_fact_registry_3"]); @@ -229,6 +238,8 @@ test("code agent session registry reports busy sessions as structured blocker", assert.equal(busy.ok, false); assert.equal(busy.code, "session_busy"); assert.equal(busy.session.status, "busy"); + assert.equal(busy.session.lifecycleStatus, "busy"); + assert.match(busy.session.lifecycle.userMessage, /会话忙\/请等待/u); assert.equal(busy.session.currentTraceId, "trc_busy_1"); }); @@ -257,6 +268,8 @@ test("code agent session registry blocks failed and interrupted sessions without 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"); const interruptedRegistry = createCodeAgentSessionRegistry({ @@ -283,13 +296,14 @@ test("code agent session registry blocks failed and interrupted sessions without assert.equal(interrupted.ok, false); assert.equal(interrupted.code, "session_interrupted"); assert.equal(interrupted.session.status, "interrupted"); + assert.equal(interrupted.session.lifecycleStatus, "interrupted"); }); test("Codex stdio readiness blocker does not use read-only session fallback", async () => { const registry = createCodeAgentSessionRegistry({ idFactory: () => "ses_chat_registry" }); - const first = await handleCodeAgentChat( + const payload = await handleCodeAgentChat( { conversationId: "cnv_chat_registry", traceId: "trc_chat_registry_pwd", @@ -307,46 +321,25 @@ test("Codex stdio readiness blocker does not use read-only session fallback", as } } ); - validateCodeAgentChatSchema(first); - assert.equal(first.status, "failed"); - assert.equal(first.provider, "codex-stdio"); - assert.equal(first.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(first.error.code, "codex_cli_binary_missing"); - assert.equal(first.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(first.runner.kind === "hwlab-readonly-runner", false); - assert.equal(first.capabilityLevel, "blocked"); - assert.equal(first.skills.status, "not_requested"); - assert.deepEqual(first.toolCalls, []); - assert.equal(Object.hasOwn(first, "reply"), false); - assert.equal(first.longLivedSessionGate.status, "blocked"); - assert.equal(first.longLivedSessionGate.pass, false); - assert.ok(first.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_cli_binary_missing")); - - const second = await handleCodeAgentChat( - { - conversationId: "cnv_chat_registry", - traceId: "trc_chat_registry_ls", - message: "ls ." - }, - { - now: () => "2026-05-23T00:01:01.000Z", - sessionRegistry: registry, - env: { - PATH: "", - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_CODEX_COMMAND: path.join(os.tmpdir(), "hwlab-missing-codex"), - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_CODEX_WORKSPACE: process.cwd() - } - } - ); - assert.equal(second.status, "failed"); - assert.equal(second.error.code, "codex_cli_binary_missing"); - assert.equal(second.provider, "codex-stdio"); - assert.equal(second.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(second.runner.kind === "hwlab-readonly-runner", false); - assert.equal(Object.hasOwn(second, "reply"), false); - assert.equal(classifyCodexRunnerCapability(second, { httpStatus: 200 }).capabilityPass, false); + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "failed"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); + assert.equal(payload.error.code, "codex_cli_binary_missing"); + assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); + assert.equal(payload.runner.kind === "hwlab-readonly-runner", false); + assert.equal(payload.capabilityLevel, "blocked"); + assert.equal(payload.skills.status, "not_requested"); + assert.deepEqual(payload.toolCalls, []); + assert.equal(payload.session, null); + assert.equal(payload.sessionLifecycleStatus, "failed"); + assert.equal(payload.sessionSummary.degraded, true); + assert.equal(payload.longLivedSessionGate.status, "blocked"); + assert.equal(payload.longLivedSessionGate.pass, false); + assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_cli_binary_missing")); + assert.equal(payload.runnerLimitations.includes("no-controlled-readonly-fallback"), true); + assert.equal(Object.hasOwn(payload, "reply"), false); + assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false); }); test("stale read-only conversation facts do not mask Codex stdio blockers", async () => { @@ -417,6 +410,180 @@ 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 () => { + const expiredRegistry = createCodeAgentSessionRegistry({ + idleTimeoutMs: 10, + idFactory: () => "ses_expired_chat" + }); + const first = expiredRegistry.acquire({ + conversationId: "cnv_expired_chat", + workspace: process.cwd(), + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_expired_seed", + now: "2026-05-23T00:02:00.000Z" + }); + expiredRegistry.release(first.session.sessionId, { + conversationId: "cnv_expired_chat", + traceId: "trc_expired_seed", + now: "2026-05-23T00:02:00.001Z" + }); + expiredRegistry.recordFact("cnv_expired_chat", { + traceId: "trc_expired_seed", + workspace: process.cwd(), + session: first.session, + runner: { kind: "hwlab-readonly-runner" }, + sessionMode: "controlled-readonly-session-registry", + capabilityLevel: "read-only-session-tools", + toolCalls: [{ name: "pwd", status: "completed" }] + }, { now: "2026-05-23T00:02:00.002Z" }); + const expiredAcquire = expiredRegistry.acquire({ + conversationId: "cnv_expired_chat", + workspace: process.cwd(), + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_expired_request", + now: "2026-05-23T00:02:01.000Z" + }); + const expired = codeAgentBlockedPayloadFromAcquire(expiredAcquire, { + conversationId: "cnv_expired_chat", + traceId: "trc_expired_request", + conversationFacts: expiredRegistry.getConversationFacts("cnv_expired_chat") + }); + assert.equal(expired.status, "failed"); + assert.equal(expired.error.code, "session_expired"); + assert.equal(expired.error.layer, "session"); + assert.equal(expired.error.retryable, true); + assert.match(expired.error.userMessage, /session 已过期/u); + assert.equal(expired.error.blocker.code, "session_expired"); + assert.equal(expired.error.blocker.traceId, "trc_expired_request"); + assert.equal(expired.session.status, "expired"); + assert.equal(expired.sessionLifecycleStatus, "expired"); + assert.equal(expired.sessionSummary.requiresNewSession, true); + assert.match(expired.sessionSummary.userMessage, /会话已过期/u); + assert.equal(expired.capabilityLevel, "blocked"); + assert.equal(expired.longLivedSessionGate.status, "blocked"); + assert.equal(expired.conversationFacts.conversationId, "cnv_expired_chat"); + assert.equal(expired.conversationFacts.sessionId, "ses_expired_chat"); + assert.equal(expired.conversationFacts.turnCount, 1); + assert.equal(expired.conversationFacts.recentToolCalls[0].name, "pwd"); + assert.equal(expired.error.blocker.conversationFacts.sessionId, "ses_expired_chat"); + + const busyRegistry = createCodeAgentSessionRegistry({ + idFactory: () => "ses_busy_chat" + }); + busyRegistry.acquire({ + conversationId: "cnv_busy_chat", + workspace: process.cwd(), + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_busy_seed", + now: "2026-05-23T00:03:00.000Z" + }); + busyRegistry.recordFact("cnv_busy_chat", { + traceId: "trc_busy_seed", + workspace: process.cwd(), + sessionId: "ses_busy_chat", + runner: { kind: "hwlab-readonly-runner" }, + sessionMode: "controlled-readonly-session-registry", + capabilityLevel: "read-only-session-tools", + toolCalls: [{ name: "skills.discover", status: "completed" }], + skills: { + status: "ready", + items: [{ name: "alpha-skill" }], + count: 1, + totalCount: 1 + } + }, { now: "2026-05-23T00:03:00.001Z" }); + const busyAcquire = busyRegistry.acquire({ + conversationId: "cnv_busy_chat", + workspace: process.cwd(), + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_busy_request", + now: "2026-05-23T00:03:01.000Z" + }); + const busy = codeAgentBlockedPayloadFromAcquire(busyAcquire, { + conversationId: "cnv_busy_chat", + traceId: "trc_busy_request", + conversationFacts: busyRegistry.getConversationFacts("cnv_busy_chat") + }); + assert.equal(busy.status, "failed"); + assert.equal(busy.error.code, "session_busy"); + assert.equal(busy.error.layer, "session"); + assert.equal(busy.error.retryable, true); + assert.match(busy.error.userMessage, /正在处理上一轮/u); + assert.equal(busy.error.blocker.code, "session_busy"); + assert.equal(busy.session.status, "busy"); + assert.equal(busy.sessionLifecycleStatus, "busy"); + assert.equal(busy.sessionSummary.busy, true); + assert.match(busy.sessionSummary.userMessage, /会话忙\/请等待/u); + assert.equal(busy.capabilityLevel, "blocked"); + assert.equal(busy.error.blockers[0].sourceIssue, "pikasTech/HWLAB#317"); + assert.equal(busy.conversationFacts.conversationId, "cnv_busy_chat"); + assert.equal(busy.conversationFacts.sessionId, "ses_busy_chat"); + assert.equal(busy.conversationFacts.latestSkills.names[0], "alpha-skill"); + + const failedRegistry = createCodeAgentSessionRegistry({ + idFactory: () => "ses_failed_chat" + }); + const failedSeed = failedRegistry.acquire({ + conversationId: "cnv_failed_chat", + workspace: process.cwd(), + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_failed_seed", + now: "2026-05-23T00:04:00.000Z" + }); + failedRegistry.fail(failedSeed.session.sessionId, { + conversationId: "cnv_failed_chat", + traceId: "trc_failed_seed", + now: "2026-05-23T00:04:00.001Z" + }); + failedRegistry.recordFact("cnv_failed_chat", { + traceId: "trc_failed_seed", + workspace: process.cwd(), + session: failedSeed.session, + runner: { kind: "hwlab-readonly-runner" }, + sessionMode: "controlled-readonly-session-registry", + capabilityLevel: "read-only-session-tools", + toolCalls: [{ name: "pwd", status: "completed" }] + }, { now: "2026-05-23T00:04:00.002Z" }); + const failedAcquire = failedRegistry.acquire({ + conversationId: "cnv_failed_chat", + workspace: process.cwd(), + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + 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"); +}); + test("OpenAI fallback hook is not used when Codex stdio is unavailable", async () => { let providerCalled = false; const payload = await handleCodeAgentChat( @@ -459,7 +626,32 @@ test("OpenAI fallback hook is not used when Codex stdio is unavailable", async ( assert.equal(payload.provider === "openai-responses", false); assert.equal(Object.hasOwn(payload, "reply"), false); assert.equal(providerCalled, false); + assert.equal(payload.sessionLifecycleStatus, "failed"); + assert.equal(payload.sessionSummary.degraded, true); + assert.equal(payload.runnerLimitations.includes("no-text-fallback"), true); assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false); + + const oneShot = { + ...payload, + provider: "codex-cli", + backend: "hwlab-cloud-api/codex-cli", + runner: { + kind: "codex-cli-one-shot-ephemeral", + codexStdio: false, + writeCapable: false, + durableSession: false, + longLivedSession: false + }, + sessionMode: "ephemeral-one-shot", + implementationType: "codex-cli-one-shot-ephemeral", + longLivedSessionGate: { + status: "blocked", + pass: false, + blockers: [{ code: "one_shot_runner_not_long_lived", sourceIssue: "pikasTech/HWLAB#317" }] + } + }; + assert.equal(classifyCodexRunnerCapability(oneShot, { httpStatus: 200 }).capabilityPass, false); + assert.equal(classifyCodeAgentChatReadiness(oneShot, { realDevLive: true, httpStatus: 200 }).devLiveReplyPass, false); }); test("OpenAI provider mode still preserves facts but does not fallback without Codex stdio", async () => { @@ -476,14 +668,29 @@ test("OpenAI provider mode still preserves facts but does not fallback without C HWLAB_CODE_AGENT_CODEX_WORKSPACE: process.cwd() }; + const seed = registry.acquire({ + conversationId: "cnv_provider_context", + workspace: process.cwd(), + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_provider_context_pwd", + now: "2026-05-23T00:04:09.000Z" + }); + const released = registry.release(seed.session.sessionId, { + conversationId: "cnv_provider_context", + traceId: "trc_provider_context_pwd", + now: "2026-05-23T00:04:09.010Z" + }); registry.recordFact("cnv_provider_context", { traceId: "trc_provider_context_pwd", workspace: process.cwd(), - runner: { kind: "codex-app-server-stdio-runner" }, - sessionMode: "codex-app-server-stdio-long-lived", - capabilityLevel: "long-lived-codex-stdio-session", + session: released, + runner: { kind: "hwlab-readonly-runner" }, + sessionMode: "controlled-readonly-session-registry", + capabilityLevel: "read-only-session-tools", toolCalls: [{ name: "pwd", status: "completed" }] - }, { now: "2026-05-23T00:04:09.000Z" }); + }, { now: "2026-05-23T00:04:09.020Z" }); const payload = await handleCodeAgentChat( { @@ -514,8 +721,10 @@ test("OpenAI provider mode still preserves facts but does not fallback without C assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); assert.equal(payload.conversationFacts.turnCount, 1); + assert.equal(payload.conversationFacts.sessionId, released.sessionId); assert.equal(payload.conversationFacts.recentToolCalls[0].name, "pwd"); assert.equal(payload.conversationFacts.latestTraceId, "trc_provider_context_pwd"); + assert.equal(payload.error.blocker.conversationFacts.sessionId, released.sessionId); assert.equal(Object.hasOwn(payload, "reply"), false); assert.equal(providerCalled, false); assert.equal(JSON.stringify(payload.conversationFacts).includes("sk-"), false); @@ -1696,6 +1905,45 @@ function createFakeAppServerClient({ calls, responses = ["first stdio reply", "s }; } +function codeAgentBlockedPayloadFromAcquire(acquireResult, { conversationId, traceId, conversationFacts }) { + const code = acquireResult.code; + const userMessage = code === "session_busy" + ? "Code Agent session 正在处理上一轮请求,请稍后重试。" + : code === "session_expired" + ? "Code Agent session 已过期,可重新发送建立新的 session。" + : "Code Agent session 已失败,可重新发送建立新的 session。"; + const sessionSummary = acquireResult.session.lifecycle; + const blocker = { + code, + sourceIssue: acquireResult.blocker.sourceIssue, + summary: acquireResult.blocker.summary, + traceId, + conversationFacts + }; + return { + status: "failed", + conversationId, + traceId, + error: { + code, + layer: "session", + retryable: true, + userMessage, + blocker, + blockers: [acquireResult.blocker] + }, + blocker, + session: acquireResult.session, + sessionLifecycleStatus: sessionSummary.status, + sessionSummary, + capabilityLevel: "blocked", + longLivedSessionGate: { + status: "blocked" + }, + conversationFacts + }; +} + function createTimedFakeAppServerClient({ calls, text = "timed stdio reply", eventEveryMs = 20, eventCount = 0, completeAfterMs = null } = {}) { let notificationHandler = null; let turn = 0; diff --git a/internal/cloud/code-agent-trace-store.mjs b/internal/cloud/code-agent-trace-store.mjs index e2781de1..322bb6e9 100644 --- a/internal/cloud/code-agent-trace-store.mjs +++ b/internal/cloud/code-agent-trace-store.mjs @@ -104,6 +104,7 @@ export function createCodeAgentTraceStore(options = {}) { implementationType: extra.implementationType ?? trace.meta.implementationType ?? null, sessionId: extra.sessionId ?? lastEvent?.sessionId ?? null, sessionStatus: extra.sessionStatus ?? lastEvent?.sessionStatus ?? null, + sessionLifecycleStatus: extra.sessionLifecycleStatus ?? lastEvent?.sessionLifecycleStatus ?? null, turn: extra.turn ?? lastEvent?.turn ?? null, outputTruncated: events.some((event) => event.outputTruncated === true), valuesPrinted: false @@ -179,6 +180,7 @@ export function createCodeAgentTraceRecorder({ sessionMode: extra.sessionMode ?? current.sessionMode, sessionId: extra.sessionId ?? current.sessionId, sessionStatus: extra.sessionStatus ?? current.sessionStatus, + sessionLifecycleStatus: extra.sessionLifecycleStatus ?? current.sessionLifecycleStatus, idleTimeoutMs: extra.idleTimeoutMs ?? null, lastTraceId: extra.lastTraceId ?? current.traceId, turn: extra.turn ?? current.turn, @@ -216,6 +218,7 @@ export function runnerTraceFromSnapshot(snapshot = {}, extra = {}) { sessionMode: extra.sessionMode ?? snapshot.sessionMode ?? null, sessionId: extra.sessionId ?? snapshot.sessionId ?? null, sessionStatus: extra.sessionStatus ?? snapshot.sessionStatus ?? null, + sessionLifecycleStatus: extra.sessionLifecycleStatus ?? snapshot.sessionLifecycleStatus ?? null, idleTimeoutMs: extra.idleTimeoutMs ?? null, lastTraceId: extra.lastTraceId ?? snapshot.traceId ?? null, turn: extra.turn ?? snapshot.turn ?? null, @@ -253,6 +256,7 @@ function normalizeTraceEvent(event, { traceId, seq, now, fallbackRunnerKind } = runnerKind: safeText(event.runnerKind ?? fallbackRunnerKind, 140), sessionId: safeText(event.sessionId, 180), sessionStatus: safeText(event.sessionStatus, 80), + sessionLifecycleStatus: safeText(event.sessionLifecycleStatus, 80), sessionReused: typeof event.sessionReused === "boolean" ? event.sessionReused : undefined, turn: typeof event.turn === "number" ? event.turn : undefined, toolName, @@ -304,6 +308,9 @@ function notify(trace, event) { sandbox: trace.meta.sandbox, sessionMode: trace.meta.sessionMode, implementationType: trace.meta.implementationType, + sessionId: event.sessionId ?? null, + sessionStatus: event.sessionStatus ?? null, + sessionLifecycleStatus: event.sessionLifecycleStatus ?? null, outputTruncated: trace.events.some((item) => item.outputTruncated === true), valuesPrinted: false } @@ -339,6 +346,7 @@ function emptySnapshot(traceId, extra = {}) { implementationType: extra.implementationType ?? null, sessionId: null, sessionStatus: null, + sessionLifecycleStatus: null, turn: null, outputTruncated: false, valuesPrinted: false diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index d921496e..0e7093c8 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -13,6 +13,12 @@ import { defaultCodeAgentTraceStore, runnerTraceFromSnapshot } from "./code-agent-trace-store.mjs"; +import { + CODE_AGENT_SESSION_LIFECYCLE_STATUSES, + CODE_AGENT_SESSION_STATUS_ALIASES, + codeAgentSessionLifecycleSummary, + decorateCodeAgentSession +} from "./code-agent-session-lifecycle.mjs"; export const CODEX_STDIO_PROVIDER = "codex-stdio"; export const CODEX_STDIO_BACKEND = "hwlab-cloud-api/codex-app-server-stdio"; @@ -281,6 +287,8 @@ export function createCodexStdioSessionManager(options = {}) { protocolProbe: protocolProbeSummary({ command, workspace, codexHome, probe: protocolProbe }), commandProbe: commandProbeState, sessionLifecycle: lifecycle, + lifecycleStatuses: [...CODE_AGENT_SESSION_LIFECYCLE_STATUSES], + statusAliases: { ...CODE_AGENT_SESSION_STATUS_ALIASES }, lifecycleSupervisor: lifecycle, recentSessions: recentSessions(), statusCounts: sessionStatusCounts(), @@ -2020,7 +2028,7 @@ export function longLivedSessionGate({ function publicSession(session, { conversationId = null, reused = null } = {}) { const conversationIds = [...session.conversationIds]; - return { + return decorateCodeAgentSession({ sessionId: session.sessionId, conversationId: conversationId ?? conversationIds[0] ?? null, conversationIds, @@ -2047,7 +2055,7 @@ function publicSession(session, { conversationId = null, reused = null } = {}) { secretMaterialStored: false, valuesRedacted: true, ...(session.statusReason ? { statusReason: session.statusReason } : {}) - }; + }, { reused }); } function blockedAcquire({ code, summary, session, timestamp, traceId }) { @@ -2102,9 +2110,11 @@ function runnerDescriptor({ workspace, sandbox, session }) { } function runnerTrace({ traceRecorder, traceId, workspace, sandbox, session, startedAt, outputTruncated }) { + const sessionLifecycleStatus = codeAgentSessionLifecycleSummary({ session }).status; const snapshot = traceRecorder?.snapshot({ sessionId: session?.sessionId, sessionStatus: session?.status, + sessionLifecycleStatus, turn: session?.turn, workspace, sandbox, @@ -2125,6 +2135,7 @@ function runnerTrace({ traceRecorder, traceId, workspace, sandbox, session, star sessionMode: CODEX_STDIO_SESSION_MODE, sessionId: session?.sessionId ?? null, sessionStatus: session?.status ?? null, + sessionLifecycleStatus, idleTimeoutMs: session?.idleTimeoutMs ?? null, lastTraceId: session?.lastTraceId ?? traceId, turn: session?.turn ?? null, @@ -2152,7 +2163,8 @@ function sessionReuseEvidence(session) { idleTimeoutMs: session.idleTimeoutMs, expiresAt: session.expiresAt, lastTraceId: session.lastTraceId, - status: session.status + status: session.status, + lifecycleStatus: session.lifecycleStatus ?? codeAgentSessionLifecycleSummary({ session }).status }; } diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index b59e8503..6dd96dfd 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -28,6 +28,7 @@ import { } from "./code-agent-chat.mjs"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.mjs"; import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs"; +import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.mjs"; import { createCodexStdioSessionManager } from "./codex-stdio-session.mjs"; import { buildGateDiagnosticsRows } from "./gate-diagnostics.mjs"; import { @@ -1313,7 +1314,31 @@ async function handleCodeAgentCancelHttp(request, response, options) { return; } - if (!sessionId || !manager || typeof manager.get !== "function" || typeof manager.cancel !== "function") { + if (!manager || typeof manager.get !== "function" || typeof manager.cancel !== "function") { + traceStore.append(traceId, { + type: "cancel", + status: "unsupported", + label: "cancel:unsupported", + errorCode: "cancel_unsupported", + message: "Codex stdio cancel/interrupt backend is not available on this runtime.", + waitingFor: "session-control" + }); + const runnerTrace = traceStore.snapshot(traceId); + sendJson(response, 501, cancelBlockedPayload({ + code: "cancel_unsupported", + message: "当前后端不支持 Code Agent interrupt/cancel;本次按 unsupported/degraded 返回,不会静默假装已中断。", + traceId, + conversationId, + sessionId, + runnerTrace, + status: "degraded", + unsupported: true, + degraded: true + })); + return; + } + + if (!sessionId) { traceStore.append(traceId, { type: "cancel", status: "blocked", @@ -1370,10 +1395,20 @@ async function handleCodeAgentCancelHttp(request, response, options) { message: "User canceled the current Codex stdio request.", sessionId, sessionStatus: canceledSession?.status ?? "canceled", + sessionLifecycleStatus: codeAgentSessionLifecycleSummary({ session: canceledSession, status: "canceled" }).status, waitingFor: "user-retry", terminal: true }); - const runnerTrace = traceStore.snapshot(traceId); + const runnerTraceSnapshot = traceStore.snapshot(traceId); + const sessionSummary = codeAgentSessionLifecycleSummary({ + session: canceledSession, + runnerTrace: runnerTraceSnapshot, + status: "canceled" + }); + const runnerTrace = { + ...runnerTraceSnapshot, + sessionLifecycleStatus: sessionSummary.status + }; sendJson(response, 200, { accepted: true, canceled: true, @@ -1382,6 +1417,9 @@ async function handleCodeAgentCancelHttp(request, response, options) { sessionId, traceId, session: canceledSession, + sessionLifecycleStatus: sessionSummary.status, + sessionLifecycle: sessionSummary, + sessionSummary, runnerTrace, lastTraceEvent: runnerTrace.lastEvent, retryable: true, @@ -1389,15 +1427,39 @@ async function handleCodeAgentCancelHttp(request, response, options) { }); } -function cancelBlockedPayload({ code, message, traceId, conversationId = null, sessionId = null, session = null, runnerTrace = null }) { +function cancelBlockedPayload({ + code, + message, + traceId, + conversationId = null, + sessionId = null, + session = null, + runnerTrace = null, + status = "failed", + unsupported = false, + degraded = false +}) { + const sessionSummary = codeAgentSessionLifecycleSummary({ + session, + runnerTrace, + status, + unsupported, + degraded, + error: { code, userMessage: message, message } + }); return { accepted: false, canceled: false, - status: "failed", + status, conversationId, sessionId, traceId, session, + sessionLifecycleStatus: sessionSummary.status, + sessionLifecycle: sessionSummary, + sessionSummary, + unsupported, + degraded, runnerTrace, lastTraceEvent: runnerTrace?.lastEvent ?? null, error: { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index bc6a1855..29de6fb8 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -3895,6 +3895,9 @@ test("cloud api /v1/agent/chat/cancel cancels in-flight Codex stdio and keeps re assert.equal(cancelPayload.canceled, true); assert.equal(cancelPayload.sessionId, "ses_server_test_cancel"); assert.equal(cancelPayload.session.status, "canceled"); + assert.equal(cancelPayload.session.lifecycleStatus, "interrupted"); + assert.equal(cancelPayload.sessionLifecycleStatus, "interrupted"); + assert.equal(cancelPayload.sessionSummary.requiresNewSession, true); assert.equal(cancelPayload.lastTraceEvent.label, "cancel:canceled"); const chatResponse = await chatPromise; @@ -3904,6 +3907,9 @@ test("cloud api /v1/agent/chat/cancel cancels in-flight Codex stdio and keeps re assert.equal(chatPayload.error.code, "codex_stdio_canceled"); assert.equal(chatPayload.session.sessionId, "ses_server_test_cancel"); assert.equal(chatPayload.session.status, "canceled"); + assert.equal(chatPayload.session.lifecycleStatus, "interrupted"); + assert.equal(chatPayload.sessionLifecycleStatus, "interrupted"); + assert.equal(chatPayload.sessionSummary.requiresNewSession, true); assert.equal(chatPayload.runnerTrace.lastEvent.label, "cancel:canceled"); const traceResponse = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/trc_server-test-cancel`); @@ -3911,6 +3917,7 @@ test("cloud api /v1/agent/chat/cancel cancels in-flight Codex stdio and keeps re const tracePayload = await traceResponse.json(); assert.equal(tracePayload.status, "canceled"); assert.equal(tracePayload.lastEvent.label, "cancel:canceled"); + assert.equal(tracePayload.sessionLifecycleStatus, "interrupted"); assert.ok(tracePayload.events.some((event) => event.label === "tool:codex:started")); } finally { await new Promise((resolve, reject) => { @@ -3920,6 +3927,60 @@ test("cloud api /v1/agent/chat/cancel cancels in-flight Codex stdio and keeps re } }); +test("cloud api /v1/agent/chat/cancel reports unsupported lifecycle degradation", async () => { + const traceStore = createCodeAgentTraceStore(); + traceStore.append("trc_cancel_unsupported", { + type: "request", + status: "running", + label: "request:running", + sessionId: "ses_cancel_unsupported", + sessionStatus: "busy", + sessionLifecycleStatus: "busy" + }); + const server = createCloudApiServer({ + traceStore, + codexStdioManager: { + get() { + return null; + } + }, + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio" + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/cancel`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trace-id": "trc_cancel_unsupported" + }, + body: JSON.stringify({ + conversationId: "cnv_cancel_unsupported", + sessionId: "ses_cancel_unsupported", + traceId: "trc_cancel_unsupported" + }) + }); + assert.equal(response.status, 501); + const payload = await response.json(); + assert.equal(payload.status, "degraded"); + assert.equal(payload.unsupported, true); + assert.equal(payload.degraded, true); + assert.equal(payload.error.code, "cancel_unsupported"); + assert.equal(payload.sessionLifecycleStatus, "failed"); + assert.equal(payload.sessionSummary.unsupported, true); + assert.match(payload.sessionSummary.userMessage, /unsupported\/degraded/u); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api /v1/agent/chat reports Codex stdio blocker instead of structured skills fallback", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-no-skills-")); const skillsDir = path.join(root, "missing-skills"); diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 621b0f78..424c58a6 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -276,6 +276,8 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId skills: null, runnerTrace: null, sessionMode: null, + sessionLifecycleStatus: null, + sessionSummary: null, sessionReuse: null, session: null, implementationType: null, @@ -325,6 +327,8 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId skills: summarizeSkills(payload.skills), runnerTrace: summarizeRunnerTrace(payload.runnerTrace), sessionMode: stringOrNull(payload.sessionMode), + sessionLifecycleStatus: stringOrNull(payload.sessionLifecycleStatus ?? payload.sessionSummary?.status ?? payload.session?.lifecycleStatus), + sessionSummary: summarizeSessionLifecycle(payload.sessionSummary ?? payload.sessionLifecycle), sessionReuse: summarizeSessionReuse(payload.sessionReuse), session: summarizeSession(payload.session), implementationType: stringOrNull(payload.implementationType), @@ -354,6 +358,8 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId skills: summarizeSkills(payload.skills), runnerTrace: summarizeRunnerTrace(payload.runnerTrace), sessionMode: stringOrNull(payload.sessionMode), + sessionLifecycleStatus: stringOrNull(payload.sessionLifecycleStatus ?? payload.sessionSummary?.status ?? payload.session?.lifecycleStatus), + sessionSummary: summarizeSessionLifecycle(payload.sessionSummary ?? payload.sessionLifecycle), sessionReuse: summarizeSessionReuse(payload.sessionReuse), session: summarizeSession(payload.session), implementationType: stringOrNull(payload.implementationType), @@ -474,7 +480,7 @@ export function classifyCodeAgentRuntimeBlock(payload, { httpStatus = null } = { }; } - if (["session_expired", "session_reuse_conflict", "session_failed", "session_interrupted"].includes(errorCode)) { + if (["session_expired", "session_reuse_conflict", "session_failed", "session_interrupted", "session_canceled", "session_timeout", "session_error"].includes(errorCode)) { return { blocked: true, status: "blocked", @@ -749,6 +755,8 @@ function summarizeRunnerTrace(value) { blocker: summarizeBlocker(value.blocker), sandbox: stringOrNull(value.sandbox), sessionMode: stringOrNull(value.sessionMode), + sessionStatus: stringOrNull(value.sessionStatus), + sessionLifecycleStatus: stringOrNull(value.sessionLifecycleStatus), implementationType: stringOrNull(value.implementationType), sessionReused: value.sessionReused === true, turn: typeof value.turn === "number" ? value.turn : null, @@ -792,6 +800,7 @@ function summarizeSession(value) { sessionId: stringOrNull(value.sessionId), conversationId: stringOrNull(value.conversationId), status: stringOrNull(value.status), + lifecycleStatus: stringOrNull(value.lifecycleStatus ?? value.lifecycle?.status), workspace: stringOrNull(value.workspace), sandbox: stringOrNull(value.sandbox), runnerKind: stringOrNull(value.runnerKind), @@ -815,6 +824,7 @@ function summarizeConversationFacts(value) { conversationId: stringOrNull(value.conversationId), sessionId: stringOrNull(value.sessionId), sessionStatus: stringOrNull(value.sessionStatus), + sessionLifecycleStatus: stringOrNull(value.sessionLifecycleStatus), sessionMode: stringOrNull(value.sessionMode), capabilityLevel: stringOrNull(value.capabilityLevel), runnerKind: stringOrNull(value.runnerKind), @@ -842,6 +852,26 @@ function summarizeConversationFacts(value) { }; } +function summarizeSessionLifecycle(value) { + if (!value || typeof value !== "object") return null; + return { + status: stringOrNull(value.status), + rawStatus: stringOrNull(value.rawStatus), + label: stringOrNull(value.label), + action: stringOrNull(value.action), + reusable: value.reusable === true, + reused: typeof value.reused === "boolean" ? value.reused : null, + newSession: value.newSession === true, + busy: value.busy === true, + requiresNewSession: value.requiresNewSession === true, + degraded: value.degraded === true, + fallback: value.fallback === true, + unsupported: value.unsupported === true, + reasonCode: stringOrNull(value.reasonCode), + userMessage: stringOrNull(value.userMessage) + }; +} + function summarizeCodexStdioFeasibility(value) { if (!value || typeof value !== "object") return null; return { diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 92488ce7..5faa0e2f 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -755,7 +755,7 @@ async function submitAgentMessage(value, options = {}) { state.sessionId = result.sessionId || result.session?.sessionId || state.sessionId || state.conversationId; const resultThreadId = threadIdFrom(result); if (resultThreadId) state.threadId = resultThreadId; - state.sessionStatus = result.session?.status ?? result.runnerTrace?.sessionStatus ?? result.status ?? state.sessionStatus; + state.sessionStatus = result.sessionLifecycleStatus ?? result.sessionSummary?.status ?? result.session?.lifecycleStatus ?? result.session?.status ?? result.runnerTrace?.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus ?? result.status ?? state.sessionStatus; const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); const sessionContinuity = completedSessionContinuity(result, pendingContinuity); const resultBlockedError = structuredBlockedErrorFromResult(result); @@ -787,6 +787,9 @@ async function submitAgentMessage(value, options = {}) { workspace: result.workspace, sandbox: result.sandbox, session: result.session, + sessionLifecycleStatus: result.sessionLifecycleStatus, + sessionLifecycle: result.sessionLifecycle, + sessionSummary: result.sessionSummary, sessionMode: result.sessionMode, sessionReuse: result.sessionReuse, implementationType: result.implementationType, @@ -930,6 +933,25 @@ function pendingSessionContinuity({ conversationId, sessionId, threadId, retryOf } function completedSessionContinuity(result, pending = null) { + const lifecycle = result?.sessionSummary ?? result?.sessionLifecycle ?? null; + if (lifecycle?.degraded === true || lifecycle?.fallback === true) { + const requested = pending?.requested ?? {}; + return { + status: "degraded", + tone: "warn", + label: lifecycle.label ?? "会话降级", + summary: lifecycle.userMessage ?? "当前响应为会话降级;输入已保留,可重新发送建立新会话。", + requested, + returned: { + conversationId: nonEmptyString(result?.conversationId), + sessionId: sessionIdFrom(result), + threadId: threadIdFrom(result) + }, + missing: [], + changed: [], + reused: lifecycle.reused ?? null + }; + } const requested = pending?.requested ?? {}; const returned = { conversationId: nonEmptyString(result?.conversationId), @@ -974,12 +996,25 @@ function failedSessionContinuity(pending = null, error = null) { const missing = [...(base.missing ?? [])]; if (!base.requested?.sessionId) missing.push("sessionId"); if (!base.requested?.threadId) missing.push("threadId"); + const code = normalizeErrorCode(error?.code); + const label = code === "session_busy" + ? "会话忙/请等待" + : code === "session_expired" + ? "会话已过期" + : ["session_failed", "session_error", "session_timeout"].includes(code) + ? "需新建会话" + : "会话受阻"; + const summary = code === "session_busy" + ? "上一轮仍在处理;输入已保留,请等待当前请求完成或取消后重试。" + : code === "session_expired" + ? "当前 session 已过期;输入已保留,可重新发送建立新的 Code Agent session。" + : `本次请求没有完成可复用会话确认;输入已保留。${error?.code ? ` code=${error.code}` : ""}`; return { ...base, status: "blocked", tone: "blocked", - label: "会话受阻", - summary: `本次请求没有完成可复用会话确认;输入已保留。${error?.code ? ` code=${error.code}` : ""}`, + label, + summary, missing: [...new Set(missing)] }; } @@ -2981,6 +3016,7 @@ function codeAgentSummaryRows(summary) { codeAgentSummaryRow("capabilityLevel", summary.capabilityLevel, summary.tone), codeAgentSummaryRow("session", summary.sessionSummary, sessionSummaryTone(summary)), codeAgentSummaryRow("sessionId/status", summary.sessionIdStatus, sessionSummaryTone(summary)), + codeAgentSummaryRow("会话提示", summary.sessionLifecycleHint, sessionSummaryTone(summary)), codeAgentSummaryRow("workspace", summary.workspace, "source"), codeAgentSummaryRow("sandbox", summary.sandbox, "source"), codeAgentSummaryRow("runnerKind", summary.runnerKind, summary.kind === "long-lived-session" ? "ok" : "source"), @@ -3308,12 +3344,20 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {}) if (code === "session_busy") { return { category: "runner_busy", - title: "Code Agent Runner 忙碌", - text: `Codex stdio runner 正在处理上一轮请求;输入已保留,稍后重试即可。${traceSuffix}` + title: "会话忙/请等待", + text: `Codex stdio runner 正在处理上一轮请求;输入已保留,请等待当前请求完成或取消后重试。${traceSuffix}` }; } - if (["session_expired", "session_reuse_conflict", "session_failed", "session_interrupted", "session_canceled"].includes(code)) { + if (code === "session_expired") { + return { + category: "session_blocked", + title: "会话已过期", + text: `当前 session 已过期;输入已保留,可重新发送建立新的 Codex stdio session。${traceSuffix}` + }; + } + + if (["session_reuse_conflict", "session_failed", "session_interrupted", "session_canceled", "session_timeout", "session_error"].includes(code)) { return { category: "session_blocked", title: "Code Agent Session 受阻", @@ -3650,6 +3694,8 @@ function messageSessionContinuityPanel(message) { ["conversation", continuity.returned?.conversationId ?? message.conversationId], ["session", continuity.returned?.sessionId ?? message.sessionId ?? "未确认"], ["thread", continuity.returned?.threadId ?? message.threadId ?? "未确认"], + ["lifecycle", message.sessionLifecycleStatus ?? message.sessionSummary?.status ?? message.session?.lifecycleStatus], + ["提示", message.sessionSummary?.userMessage ?? message.sessionLifecycle?.userMessage], ["requestedSession", continuity.requested?.sessionId], ["requestedThread", continuity.requested?.threadId], ["retryOf", continuity.requested?.retryOf ?? message.retryOf], @@ -3692,6 +3738,7 @@ function messagePendingContextPanel(message) { ["session", message.sessionId ?? message.runnerTrace?.sessionId ?? "等待后端分配"], ["thread", message.threadId ?? message.runnerTrace?.threadId ?? "等待后端分配"], ["sessionStatus", message.runnerTrace?.sessionStatus], + ["lifecycle", message.runnerTrace?.sessionLifecycleStatus], ["activityTimeout", `${CODE_AGENT_TIMEOUT_MS}ms 无新事件`] ]; const section = document.createElement("section"); diff --git a/web/hwlab-cloud-web/code-agent-status.mjs b/web/hwlab-cloud-web/code-agent-status.mjs index 4c4f4e4a..901066e3 100644 --- a/web/hwlab-cloud-web/code-agent-status.mjs +++ b/web/hwlab-cloud-web/code-agent-status.mjs @@ -28,6 +28,10 @@ export function classifyCodeAgentStatusSummary({ availability = null, latestMess const sessionId = safeField(firstString(payload.session?.sessionId, payload.sessionId, payload.runner?.sessionId, payload.sessionReuse?.sessionId)); const threadId = safeField(firstString(payload.threadId, payload.session?.threadId, payload.sessionReuse?.threadId, payload.providerTrace?.threadId, payload.runnerTrace?.threadId)); const sessionStatus = safeField(firstString(payload.session?.status, payload.sessionStatus, payload.runnerTrace?.sessionStatus, payload.sessionReuse?.status)); + const lifecycle = sessionLifecycleSummary(payload); + const sessionLifecycleStatus = safeField(lifecycle.status); + const sessionLifecycleHint = safeField(lifecycle.hint); + const sessionLifecycleLabel = safeField(lifecycle.label); const workspace = safeField(firstString(payload.workspace, payload.session?.workspace, payload.runner?.workspace, payload.sessionReuse?.workspace)); const sandbox = safeField(firstString(payload.sandbox, payload.session?.sandbox, payload.runner?.sandbox)); const runnerKind = safeField(firstString(payload.runner?.kind, payload.session?.runnerKind, payload.runnerTrace?.runnerKind)); @@ -66,8 +70,11 @@ export function classifyCodeAgentStatusSummary({ availability = null, latestMess sessionId, threadId, sessionStatus, - sessionSummary: compactJoin([sessionMode, sessionStatus], " / "), - sessionIdStatus: compactJoin([sessionId, threadId, sessionStatus], " / "), + sessionLifecycleStatus, + sessionLifecycleHint, + sessionLifecycleLabel, + sessionSummary: compactJoin([sessionMode, sessionStatus, sessionLifecycleStatus], " / "), + sessionIdStatus: compactJoin([sessionId, threadId, sessionStatus, sessionLifecycleStatus], " / "), workspace, sandbox, runnerKind, @@ -95,6 +102,9 @@ export function classifyCodeAgentStatusSummary({ availability = null, latestMess sessionId, threadId, sessionStatus, + sessionLifecycleStatus, + sessionLifecycleHint, + sessionLifecycleLabel, workspace, sandbox, runnerKind, @@ -151,8 +161,22 @@ function classifyPayload(payload, blockers) { return status("unverified", "探测中", "pending", "○", "等待同源接口返回 Code Agent 状态。"); } - if (payload.status === "running" || payload.session?.status === "busy" || payload.runnerTrace?.sessionStatus === "busy") { - return status("busy", "请求处理中", "pending", "●", "当前 Codex stdio 请求仍在 in-flight 状态,trace 会继续更新。"); + const lifecycle = sessionLifecycleSummary(payload); + + if (payload.status === "running" || lifecycle.status === "busy" || payload.session?.status === "busy" || payload.runnerTrace?.sessionStatus === "busy") { + return status("busy", "会话忙/请等待", "pending", "●", lifecycle.hint || "当前 Codex stdio 请求仍在 in-flight 状态,trace 会继续更新。"); + } + + if (lifecycle.status === "expired") { + return status("session-expired", "会话已过期", "blocked", "×", lifecycle.hint || "会话已过期,可重新发送建立新的 Code Agent session。"); + } + + 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 已失败,需重新发送建立新会话。"); + } + + if (lifecycle.status === "interrupted" && !["canceled", "timeout", "error"].includes(payload.status)) { + return status("session-interrupted", lifecycle.label || "会话已中断", "blocked", "×", lifecycle.hint || "当前 Code Agent session 已中断,可重试或重新发送。"); } if (payload.status === "canceled" || payload.session?.status === "canceled" || payload.error?.code === "codex_stdio_canceled") { @@ -200,7 +224,7 @@ function classifyPayload(payload, blockers) { } if (isFallbackTextChatOnly(payload)) { - return status("fallback-text-chat-only", "文本备用通道", "warn", "◌", "只有文本对话能力,没有 workspace tools 或 session 控制。"); + return status("fallback-text-chat-only", "会话降级", "warn", "◌", lifecycle.hint || "只有文本对话能力,没有 workspace tools 或 session 控制。"); } if (isDegraded(payload, blockers)) { @@ -393,6 +417,106 @@ function isBlocked(payload) { Boolean(payload.error); } +function sessionLifecycleSummary(payload = {}) { + let status = normalizeSessionLifecycleStatus(firstString( + payload?.sessionLifecycleStatus, + payload?.sessionLifecycle?.status, + payload?.sessionSummary?.status, + payload?.session?.lifecycleStatus, + payload?.session?.lifecycle?.status, + payload?.runnerTrace?.sessionLifecycleStatus, + payload?.session?.status, + payload?.sessionStatus, + payload?.runnerTrace?.sessionStatus, + payload?.sessionReuse?.status, + payload?.status + ), { + fallback: fallbackLifecycleStatus(payload) + }); + const fallback = isFallbackTextChatOnly(payload) || + payload?.fallbackUsed === true || + payload?.sessionLifecycle?.fallback === true || + payload?.sessionSummary?.fallback === true; + const degraded = fallback || + payload?.degraded === true || + payload?.sessionLifecycle?.degraded === true || + payload?.sessionSummary?.degraded === true || + payload?.longLivedSessionGate?.status === "blocked" || + payload?.blocker?.code === "code_agent_session_evidence_missing" || + payload?.error?.code === "code_agent_session_evidence_missing"; + const evidenceSessionId = firstString( + payload?.session?.sessionId, + payload?.sessionReuse?.sessionId, + payload?.runnerTrace?.sessionId + ); + const topLevelSessionId = firstString(payload?.sessionId); + const hasSession = Boolean(evidenceSessionId || (topLevelSessionId && topLevelSessionId !== firstString(payload?.conversationId))); + if ((fallback || degraded) && !hasSession) status = "failed"; + const concreteLifecycleBlock = ["busy", "expired", "failed", "interrupted"].includes(status) && hasSession; + const evidenceMissing = payload?.blocker?.code === "code_agent_session_evidence_missing" || + payload?.error?.code === "code_agent_session_evidence_missing"; + const reused = payload?.sessionSummary?.reused ?? + payload?.sessionLifecycle?.reused ?? + payload?.sessionReuse?.reused ?? + payload?.session?.reused ?? + payload?.runnerTrace?.sessionReused ?? + null; + if (fallback) { + return { + status: status ?? "failed", + label: "会话降级", + hint: "会话降级:当前响应来自 fallback/text-only/read-only 路径,不按完整 Code Agent 长会话成功展示。" + }; + } + if (degraded && (evidenceMissing || !concreteLifecycleBlock)) { + return { + status: status ?? "failed", + label: "会话降级", + hint: "会话降级:会话证据不完整或运行路径降级,输入已保留,可重新发送建立新会话。" + }; + } + if (status === "busy") { + return { status, label: "会话忙", hint: "会话忙/请等待:上一轮仍在处理,稍后重试或取消当前请求。" }; + } + if (status === "expired") { + return { status, label: "会话已过期", hint: "会话已过期,可重新发送建立新的 Code Agent session。" }; + } + if (status === "failed") { + return { status, label: "会话失败", hint: "当前 Code Agent session 已失败,需重新发送建立新会话。" }; + } + if (status === "interrupted") { + return { status, label: "会话已中断", hint: "当前 Code Agent session 已中断;输入和 trace 已保留,可重试或重新发送。" }; + } + if (status === "creating") { + return { status, label: "正在建立会话", hint: "正在建立 Code Agent session,请等待后端返回 session/thread。" }; + } + if (status === "ready" || status === "idle") { + return reused === true + ? { status, label: "继续当前会话", hint: "可继续当前会话:后端已确认复用现有 conversation/session/thread。" } + : { status, label: "新会话已建立", hint: "新会话已建立:后端返回了可复用的 session/thread。" }; + } + return { status, label: "会话未确认", hint: "尚未观测到可复用的 Code Agent session 状态。" }; +} + +function normalizeSessionLifecycleStatus(value, { fallback = null } = {}) { + const text = firstString(value); + const status = String(text ?? "").toLowerCase(); + if (["creating", "ready", "busy", "idle", "interrupted", "expired", "failed"].includes(status)) return status; + if (status === "canceled" || status === "cancelled") return "interrupted"; + if (status === "timeout" || status === "error" || status === "blocked" || status === "degraded") return "failed"; + if (status === "running") return "busy"; + if (status === "completed" || status === "available" || status === "feasible" || status === "pass") return "idle"; + return fallback; +} + +function fallbackLifecycleStatus(payload) { + if (isFallbackTextChatOnly(payload) || payload?.degraded === true) return "failed"; + if (payload?.status === "running") return "busy"; + if (["failed", "timeout", "error"].includes(payload?.status)) return "failed"; + if (payload?.status === "canceled") return "interrupted"; + return null; +} + function isDegraded(payload, blockers) { return payload.status === "partial" || payload.status === "degraded" || diff --git a/web/hwlab-cloud-web/code-agent-status.test.mjs b/web/hwlab-cloud-web/code-agent-status.test.mjs index 6f9d4e1d..3f2bdce0 100644 --- a/web/hwlab-cloud-web/code-agent-status.test.mjs +++ b/web/hwlab-cloud-web/code-agent-status.test.mjs @@ -56,7 +56,9 @@ test("summarizes long-lived Code Agent as green with current deployment revision assert.equal(summary.codeAgentStatus, "codex-stdio-feasible"); assert.equal(summary.providerModeBackend, "codex-stdio / codex-stdio / hwlab-cloud-api/codex-stdio"); assert.equal(summary.capabilityLevel, "long-lived-codex-stdio-session"); - assert.equal(summary.sessionSummary, "codex-app-server-stdio-long-lived / idle"); + assert.equal(summary.sessionSummary, "codex-app-server-stdio-long-lived / idle / idle"); + assert.equal(summary.sessionLifecycleStatus, "idle"); + assert.match(summary.sessionLifecycleHint, /新会话已建立/u); assert.equal(summary.runnerKind, "codex-app-server-stdio-runner"); assert.equal(summary.protocol, "codex-app-server-jsonrpc-stdio"); assert.equal(summary.implementationType, "repo-owned-codex-app-server-stdio-session"); @@ -142,7 +144,9 @@ test("summarizes Codex app-server stdio thread as reusable long-lived session", assert.equal(summary.kind, "long-lived-session"); assert.equal(summary.tone, "ok"); - assert.equal(summary.sessionIdStatus, "ses_app_server / thread_app_server / idle"); + assert.equal(summary.sessionIdStatus, "ses_app_server / thread_app_server / idle / idle"); + assert.equal(summary.sessionLifecycleLabel, "继续当前会话"); + assert.match(summary.sessionLifecycleHint, /可继续当前会话/u); assert.equal(summary.fields.threadId, "thread_app_server"); }); @@ -216,7 +220,7 @@ test("maps degraded readonly session tools to warning, not green", () => { assert.equal(summary.label, "只读长会话工具"); assert.equal(summary.tone, "warn"); assert.equal(summary.sessionId, "ses_readonly"); - assert.equal(summary.sessionIdStatus, "ses_readonly / idle"); + assert.equal(summary.sessionIdStatus, "ses_readonly / idle / idle"); assert.equal(summary.workspace, "/workspace/hwlab"); assert.equal(summary.sandbox, "read-only"); assert.equal(summary.runnerKind, "hwlab-readonly-runner"); @@ -271,10 +275,12 @@ test("maps text fallback to warning with safe labels", () => { }); assert.equal(summary.kind, "fallback-text-chat-only"); - assert.equal(summary.label, "文本备用通道"); + assert.equal(summary.label, "会话降级"); assert.equal(summary.tone, "warn"); assert.equal(summary.provider, "openai-responses"); assert.equal(summary.capabilityLevel, "text-chat-only"); + assert.equal(summary.sessionLifecycleStatus, "failed"); + assert.match(summary.sessionLifecycleHint, /fallback\/text-only\/read-only/u); }); test("maps stateless one-shot runner separately from long-lived sessions", () => { @@ -313,10 +319,12 @@ test("summarizes active Code Agent running request as pending with trace and ses }); assert.equal(summary.kind, "busy"); - assert.equal(summary.label, "请求处理中"); + assert.equal(summary.label, "会话忙/请等待"); assert.equal(summary.tone, "pending"); assert.equal(summary.codeAgentStatus, "running"); assert.equal(summary.sessionId, "cnv_pending_heavy_skills"); + assert.equal(summary.sessionLifecycleStatus, "busy"); + assert.match(summary.sessionLifecycleHint, /会话忙\/请等待/u); assert.equal(summary.lastTraceId, "trc_pending_heavy_skills"); assert.equal(summary.readinessBlockers.length, 0); }); @@ -355,6 +363,8 @@ test("summarizes canceled Code Agent request with preserved retry context", () = assert.equal(summary.codeAgentStatus, "canceled"); assert.equal(summary.sessionId, "ses_cancel_keep_context"); assert.equal(summary.sessionStatus, "canceled"); + assert.equal(summary.sessionLifecycleStatus, "interrupted"); + assert.match(summary.sessionLifecycleHint, /已中断/u); assert.equal(summary.lastTraceId, "trc_cancel_keep_context"); }); @@ -392,9 +402,74 @@ test("summarizes timed out Code Agent request with preserved trace context", () assert.equal(summary.codeAgentStatus, "timeout"); assert.equal(summary.sessionId, "ses_timeout_keep_context"); assert.equal(summary.sessionStatus, "timeout"); + assert.equal(summary.sessionLifecycleStatus, "failed"); assert.equal(summary.lastTraceId, "trc_timeout_keep_context"); }); +test("surfaces expired and failed Code Agent session lifecycle hints", () => { + const expired = classifyCodeAgentStatusSummary({ + latestMessage: { + status: "failed", + provider: "codex-stdio", + session: { + sessionId: "ses_expired_ui", + status: "expired", + codexStdio: true + }, + error: { + code: "session_expired" + } + } + }); + assert.equal(expired.kind, "session-expired"); + assert.equal(expired.label, "会话已过期"); + assert.equal(expired.sessionLifecycleStatus, "expired"); + assert.match(expired.sessionLifecycleHint, /重新发送建立新的 Code Agent session/u); + + const failed = classifyCodeAgentStatusSummary({ + latestMessage: { + status: "failed", + provider: "codex-stdio", + session: { + sessionId: "ses_failed_ui", + status: "failed", + codexStdio: true + }, + error: { + code: "session_failed" + } + } + }); + assert.equal(failed.kind, "session-failed"); + assert.equal(failed.label, "会话失败"); + assert.equal(failed.sessionLifecycleStatus, "failed"); + assert.match(failed.sessionLifecycleHint, /需重新发送建立新会话/u); +}); + +test("missing Code Agent session evidence is shown as lifecycle degradation", () => { + const summary = classifyCodeAgentStatusSummary({ + latestMessage: { + status: "completed", + provider: "codex-stdio", + capabilityLevel: "long-lived-codex-stdio-session", + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session", + runner: { + kind: "codex-app-server-stdio-runner", + codexStdio: true, + writeCapable: true + }, + blocker: { + code: "code_agent_session_evidence_missing" + } + } + }); + + assert.notEqual(summary.tone, "ok"); + assert.equal(summary.sessionLifecycleLabel, "会话降级"); + assert.match(summary.sessionLifecycleHint, /会话证据不完整/u); +}); + test("maps HWLAB Skill CLI API control to distinct green state when ready", () => { const summary = classifyCodeAgentStatusSummary({ latestMessage: {