落实 Code Agent session 生命周期状态
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user