落实 Code Agent session 生命周期状态

This commit is contained in:
unidesk-code-queue-runner
2026-05-24 12:11:45 +00:00
parent 57bc01b648
commit 3caa32a811
12 changed files with 1076 additions and 77 deletions
+66 -6
View File
@@ -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 = {}) {
@@ -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;
}
+26 -2
View File
@@ -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);
@@ -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;
@@ -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
+15 -3
View File
@@ -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
};
}
+66 -4
View File
@@ -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: {
+61
View File
@@ -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");
+31 -1
View File
@@ -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 {
+53 -6
View File
@@ -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");
+129 -5
View File
@@ -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" ||
+80 -5
View File
@@ -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: {