960 lines
40 KiB
JavaScript
960 lines
40 KiB
JavaScript
const trustedLiveProviders = new Set(["openai-responses", "codex-cli", "codex-readonly-runner", "codex-stdio"]);
|
||
const trustedRunnerProviders = new Set(["codex-stdio"]);
|
||
const partialRunnerProviders = new Set(["codex-readonly-runner"]);
|
||
const trustedCodexSandboxes = new Set(["workspace-write", "danger-full-access"]);
|
||
const readonlySessionCapabilityLevels = new Set(["read-only-tools", "read-only-session-tools"]);
|
||
const untrustedProviderPattern = /\b(?:echo|mock|fixture|stub|sample)\b/iu;
|
||
const blockedCodeAgentUiLabels = new Set([
|
||
"后端失败",
|
||
"服务受阻",
|
||
"等待超时",
|
||
"Provider 不可用",
|
||
"Runner 忙碌",
|
||
"Session 受阻",
|
||
"Runner 受阻",
|
||
"API 错误",
|
||
"需要配置",
|
||
"能力未开放",
|
||
"安全阻断",
|
||
"仍是 fallback",
|
||
"BLOCKED 凭证缺口"
|
||
]);
|
||
const timeoutPattern = /\b(?:timeout|timed out|abort|aborted|超时|请求超过|无新事件)\b/iu;
|
||
|
||
export function classifyCodeAgentChatReadiness(payload, { realDevLive = false, httpStatus = null } = {}) {
|
||
const providerBlock = classifyCodeAgentProviderBlock(payload, { httpStatus });
|
||
if (providerBlock.blocked) {
|
||
return {
|
||
status: "blocked",
|
||
level: providerBlock.level,
|
||
blocker: providerBlock.blocker,
|
||
devLiveReplyPass: false,
|
||
reason: providerBlock.reason,
|
||
...(providerBlock.providerStatus != null ? { providerStatus: providerBlock.providerStatus } : {})
|
||
};
|
||
}
|
||
|
||
const assistantReply = typeof payload?.reply?.content === "string" ? payload.reply.content.trim() : "";
|
||
if (payload?.status === "completed" && assistantReply) {
|
||
const evidence = inspectRealCompletionEvidence(payload);
|
||
if (!evidence.ok) {
|
||
return {
|
||
status: "blocked",
|
||
level: realDevLive ? "BLOCKED/untrusted-completion" : "SOURCE",
|
||
blocker: "untrusted-completion",
|
||
devLiveReplyPass: false,
|
||
reason: `completed response is missing real provider/model/trace/conversation evidence or looks like echo/mock/stub: ${evidence.reason}`
|
||
};
|
||
}
|
||
if (realDevLive) {
|
||
return {
|
||
status: "pass",
|
||
level: "#143 DEV-LIVE reply pass",
|
||
blocker: null,
|
||
devLiveReplyPass: true,
|
||
reason: "real DEV /v1/agent/chat completed with a non-empty assistant reply"
|
||
};
|
||
}
|
||
return {
|
||
status: "blocked",
|
||
level: "SOURCE",
|
||
blocker: "not-dev-live",
|
||
devLiveReplyPass: false,
|
||
reason: "mock, fixture, local stub, or source-only completion cannot be counted as #143 DEV-LIVE reply pass"
|
||
};
|
||
}
|
||
|
||
const suffix = httpStatus ? ` (HTTP ${httpStatus})` : "";
|
||
return {
|
||
status: "blocked",
|
||
level: "BLOCKED",
|
||
blocker: "runtime",
|
||
devLiveReplyPass: false,
|
||
reason: `Code Agent chat did not produce a completed non-empty assistant reply${suffix}`
|
||
};
|
||
}
|
||
|
||
export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {}) {
|
||
const providerBlock = classifyCodeAgentProviderBlock(payload, { httpStatus });
|
||
if (providerBlock.blocked) {
|
||
return {
|
||
status: "blocked",
|
||
level: providerBlock.level,
|
||
blocker: providerBlock.blocker,
|
||
capabilityPass: false,
|
||
reason: providerBlock.reason,
|
||
...(providerBlock.providerStatus != null ? { providerStatus: providerBlock.providerStatus } : {})
|
||
};
|
||
}
|
||
|
||
const provider = stringOrNull(payload?.provider);
|
||
const runnerKind = stringOrNull(payload?.runner?.kind);
|
||
const capabilityLevel = stringOrNull(payload?.capabilityLevel);
|
||
const workspace = stringOrNull(payload?.workspace ?? payload?.runner?.workspace);
|
||
const sandbox = stringOrNull(payload?.sandbox ?? payload?.runner?.sandbox);
|
||
const toolCalls = Array.isArray(payload?.toolCalls) ? payload.toolCalls : [];
|
||
const runnerTrace = payload?.runnerTrace && typeof payload.runnerTrace === "object" ? payload.runnerTrace : null;
|
||
const skills = payload?.skills && typeof payload.skills === "object" ? payload.skills : null;
|
||
const sessionMode = stringOrNull(payload?.sessionMode ?? payload?.runner?.sessionMode ?? payload?.runner?.session);
|
||
const implementationType = stringOrNull(payload?.implementationType ?? payload?.runner?.implementationType);
|
||
const runnerLimitations = Array.isArray(payload?.runnerLimitations)
|
||
? payload.runnerLimitations.filter((item) => typeof item === "string")
|
||
: Array.isArray(payload?.runner?.runnerLimitations)
|
||
? payload.runner.runnerLimitations.filter((item) => typeof item === "string")
|
||
: [];
|
||
const sessionReuse = payload?.sessionReuse && typeof payload.sessionReuse === "object" ? payload.sessionReuse : null;
|
||
const session = payload?.session && typeof payload.session === "object" ? payload.session : null;
|
||
const longLivedSessionGate = payload?.longLivedSessionGate && typeof payload.longLivedSessionGate === "object"
|
||
? payload.longLivedSessionGate
|
||
: null;
|
||
|
||
if (provider === "openai-responses" || runnerKind === "openai-responses-fallback") {
|
||
return {
|
||
status: "blocked",
|
||
level: "BLOCKED/text-chat-only",
|
||
blocker: "openai-fallback-not-runner",
|
||
capabilityPass: false,
|
||
reason: "OpenAI Responses fallback can answer text, but it has no workspace/tools/skills runner evidence."
|
||
};
|
||
}
|
||
|
||
const missing = [];
|
||
if (payload?.status !== "completed") missing.push("completed status");
|
||
if (!trustedRunnerProviders.has(provider)) missing.push("runner provider");
|
||
if (runnerKind !== "codex-app-server-stdio-runner") missing.push("runner.kind=codex-app-server-stdio-runner");
|
||
if (capabilityLevel !== "long-lived-codex-stdio-session") missing.push("capabilityLevel=long-lived-codex-stdio-session");
|
||
if (!workspace) missing.push("workspace");
|
||
if (!trustedCodexSandboxes.has(sandbox)) missing.push("sandbox=workspace-write|danger-full-access");
|
||
if (sessionMode !== "codex-app-server-stdio-long-lived") missing.push("sessionMode=codex-app-server-stdio-long-lived");
|
||
if (implementationType !== "repo-owned-codex-app-server-stdio-session") missing.push("implementationType=repo-owned-codex-app-server-stdio-session");
|
||
if (!session) missing.push("session");
|
||
if (session && !["idle", "ready", "busy"].includes(session.status)) missing.push("session.status active/idle");
|
||
if (session && typeof session.idleTimeoutMs !== "number") missing.push("session.idleTimeoutMs");
|
||
if (session && !session.lastTraceId) missing.push("session.lastTraceId");
|
||
if (session && session.longLivedSession !== true) missing.push("session.longLivedSession=true");
|
||
if (session && session.codexStdio !== true) missing.push("session.codexStdio=true");
|
||
if (!sessionReuse) missing.push("sessionReuse");
|
||
if (toolCalls.length === 0) missing.push("toolCalls");
|
||
if (!runnerTrace) missing.push("runnerTrace");
|
||
if (!skills) missing.push("skills");
|
||
if (payload?.runner?.codexStdio !== true) missing.push("runner.codexStdio=true");
|
||
if (payload?.runner?.writeCapable !== true) missing.push("runner.writeCapable=true");
|
||
if (payload?.runner?.durableSession !== true) missing.push("runner.durableSession=true");
|
||
if (!longLivedSessionGate) missing.push("longLivedSessionGate");
|
||
if (longLivedSessionGate?.status !== "pass" || longLivedSessionGate?.pass !== true) missing.push("longLivedSessionGate=pass");
|
||
|
||
if (missing.length > 0) {
|
||
if (partialRunnerProviders.has(provider) && runnerKind === "hwlab-readonly-runner") {
|
||
return {
|
||
status: "blocked",
|
||
level: "BLOCKED/controlled-readonly-long-lived-session",
|
||
blocker: "controlled-readonly-not-long-lived",
|
||
capabilityPass: false,
|
||
reason: "controlled-readonly-session-registry exposes a reusable read-only session, but it is not Codex stdio, a write-capable sandbox, or a full Code Agent capability pass."
|
||
};
|
||
}
|
||
return {
|
||
status: "blocked",
|
||
level: "BLOCKED/runner-capability",
|
||
blocker: "runner-evidence-missing",
|
||
capabilityPass: false,
|
||
reason: `Codex runner capability evidence is incomplete: ${missing.join(", ")}`
|
||
};
|
||
}
|
||
|
||
if (toolCalls.some((tool) => tool.status !== "completed")) {
|
||
return {
|
||
status: "blocked",
|
||
level: "BLOCKED/tool",
|
||
blocker: "tool-unavailable",
|
||
capabilityPass: false,
|
||
reason: "At least one runner tool call is not completed."
|
||
};
|
||
}
|
||
|
||
return {
|
||
status: "pass",
|
||
level: "#275 long-lived Codex stdio session skeleton pass",
|
||
blocker: null,
|
||
capabilityPass: true,
|
||
longLivedSession: true,
|
||
reason: "completed repo-owned Codex stdio response includes reusable session, write-capable sandbox, toolCalls, skills, runnerTrace, and a passed long-lived session gate"
|
||
};
|
||
}
|
||
|
||
export function classifyCodeAgentProviderBlock(payload, { httpStatus = null } = {}) {
|
||
const payloadStatus = stringOrNull(payload?.status);
|
||
const errorCode = stringOrNull(payload?.error?.code);
|
||
const availabilityStatus = stringOrNull(payload?.availability?.status);
|
||
const missingEnv = Array.isArray(payload?.error?.missingEnv)
|
||
? payload.error.missingEnv.filter((item) => typeof item === "string")
|
||
: [];
|
||
const providerStatus = numericStatus(payload?.error?.providerStatus);
|
||
const responseStatus = numericStatus(httpStatus);
|
||
const providerHttpBlocked = isHttpNon2xxStatus(providerStatus);
|
||
const responseHttpBlocked = isHttpNon2xxStatus(responseStatus);
|
||
const httpBlocked = providerHttpBlocked || responseHttpBlocked;
|
||
const blockingHttpStatus = providerHttpBlocked ? providerStatus : responseHttpBlocked ? responseStatus : providerStatus ?? responseStatus;
|
||
const providerUnavailable =
|
||
errorCode === "provider_unavailable" ||
|
||
availabilityStatus === "blocked" ||
|
||
payloadStatus === "blocked" ||
|
||
httpBlocked;
|
||
|
||
if (!providerUnavailable) {
|
||
return { blocked: false };
|
||
}
|
||
|
||
if (httpBlocked) {
|
||
return {
|
||
blocked: true,
|
||
level: "BLOCKED/provider",
|
||
blocker: "provider-upstream",
|
||
providerStatus: blockingHttpStatus,
|
||
missingEnv,
|
||
reason: `Code Agent HTTP non-2xx status ${blockingHttpStatus} means the upstream response is blocked, not completed`
|
||
};
|
||
}
|
||
|
||
if (missingEnv.includes("OPENAI_API_KEY")) {
|
||
return {
|
||
blocked: true,
|
||
level: "BLOCKED/credential",
|
||
blocker: "credential",
|
||
providerStatus: blockingHttpStatus,
|
||
missingEnv,
|
||
reason: "provider_unavailable with missing OPENAI_API_KEY means the provider credential contract is incomplete"
|
||
};
|
||
}
|
||
|
||
if (missingEnv.includes("HWLAB_CODE_AGENT_OPENAI_BASE_URL")) {
|
||
return {
|
||
blocked: true,
|
||
level: "BLOCKED/provider-config",
|
||
blocker: "provider-config",
|
||
providerStatus: blockingHttpStatus,
|
||
missingEnv,
|
||
reason: "provider_unavailable with missing HWLAB_CODE_AGENT_OPENAI_BASE_URL means the provider egress/base-url contract is incomplete"
|
||
};
|
||
}
|
||
|
||
return {
|
||
blocked: true,
|
||
level: "BLOCKED/provider",
|
||
blocker: "provider-unavailable",
|
||
providerStatus: blockingHttpStatus,
|
||
missingEnv,
|
||
reason: "provider_unavailable or availability.status=blocked means the provider path is blocked, not completed"
|
||
};
|
||
}
|
||
|
||
export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId = null } = {}) {
|
||
const providerBlock = classifyCodeAgentProviderBlock(payload, { httpStatus });
|
||
const hasObjectPayload = payload && typeof payload === "object" && !Array.isArray(payload);
|
||
|
||
if (!hasObjectPayload) {
|
||
if (!providerBlock.blocked) return null;
|
||
return {
|
||
status: "failed",
|
||
sourceKind: null,
|
||
evidenceLevel: null,
|
||
provider: "unknown",
|
||
model: "unknown",
|
||
backend: "unknown",
|
||
traceId: traceId || "unknown",
|
||
hasReply: false,
|
||
error: {
|
||
code: "provider_unavailable",
|
||
missingEnv: [],
|
||
missingCommands: [],
|
||
providerStatus: providerBlock.providerStatus
|
||
},
|
||
runner: null,
|
||
workspace: null,
|
||
sandbox: null,
|
||
capabilityLevel: null,
|
||
toolCalls: [],
|
||
skills: null,
|
||
runnerTrace: null,
|
||
sessionMode: null,
|
||
sessionLifecycleStatus: null,
|
||
sessionSummary: null,
|
||
sessionReuse: null,
|
||
session: null,
|
||
implementationType: null,
|
||
runnerLimitations: [],
|
||
codexStdioFeasibility: null,
|
||
longLivedSessionGate: null,
|
||
conversationFacts: null
|
||
};
|
||
}
|
||
|
||
const error = normalizeCodeAgentError(payload.error);
|
||
const assistantReply = typeof payload.reply?.content === "string" ? payload.reply.content.trim() : "";
|
||
const provider = stringOrNull(payload.provider) ?? (providerBlock.blocked ? "unknown" : null);
|
||
const model = stringOrNull(payload.model) ?? (providerBlock.blocked ? "unknown" : null);
|
||
const backend = stringOrNull(payload.backend) ?? (providerBlock.blocked ? "unknown" : null);
|
||
const observedTraceId = stringOrNull(payload.traceId) ?? traceId ?? (providerBlock.blocked ? "unknown" : null);
|
||
|
||
if (providerBlock.blocked) {
|
||
const blockedError = error
|
||
? {
|
||
...error,
|
||
code: error.code ?? "provider_unavailable",
|
||
providerStatus: error.providerStatus ?? providerBlock.providerStatus
|
||
}
|
||
: {
|
||
code: "provider_unavailable",
|
||
missingEnv: providerBlock.missingEnv ?? [],
|
||
missingCommands: [],
|
||
providerStatus: providerBlock.providerStatus
|
||
};
|
||
return {
|
||
status: "failed",
|
||
sourceKind: stringOrNull(payload.sourceKind),
|
||
evidenceLevel: stringOrNull(payload.evidenceLevel),
|
||
provider,
|
||
model,
|
||
backend,
|
||
traceId: observedTraceId,
|
||
hasReply: false,
|
||
error: blockedError,
|
||
blocker: summarizeBlocker(payload.blocker ?? payload.error?.blocker),
|
||
runner: summarizeRunner(payload),
|
||
workspace: stringOrNull(payload.workspace),
|
||
sandbox: stringOrNull(payload.sandbox),
|
||
capabilityLevel: stringOrNull(payload.capabilityLevel),
|
||
toolCalls: summarizeToolCalls(payload.toolCalls),
|
||
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),
|
||
runnerLimitations: summarizeStringList(payload.runnerLimitations),
|
||
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility),
|
||
longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate),
|
||
conversationFacts: summarizeConversationFacts(payload.conversationFacts)
|
||
};
|
||
}
|
||
|
||
return {
|
||
status: stringOrNull(payload.status),
|
||
sourceKind: stringOrNull(payload.sourceKind),
|
||
evidenceLevel: stringOrNull(payload.evidenceLevel),
|
||
provider,
|
||
model,
|
||
backend,
|
||
traceId: observedTraceId,
|
||
hasReply: assistantReply.length > 0,
|
||
error,
|
||
blocker: summarizeBlocker(payload.blocker ?? payload.error?.blocker),
|
||
runner: summarizeRunner(payload),
|
||
workspace: stringOrNull(payload.workspace),
|
||
sandbox: stringOrNull(payload.sandbox),
|
||
capabilityLevel: stringOrNull(payload.capabilityLevel),
|
||
toolCalls: summarizeToolCalls(payload.toolCalls),
|
||
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),
|
||
runnerLimitations: summarizeStringList(payload.runnerLimitations),
|
||
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility),
|
||
longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate),
|
||
conversationFacts: summarizeConversationFacts(payload.conversationFacts)
|
||
};
|
||
}
|
||
|
||
export function classifyCodeAgentBrowserJourney({ responseSummary, httpOk = false, httpStatus = null, ui = null } = {}) {
|
||
const providerBlock = classifyCodeAgentProviderBlock(responseSummary, { httpStatus });
|
||
const runtimeBlock = classifyCodeAgentRuntimeBlock(responseSummary, { httpStatus });
|
||
const uiCompleted = ui?.agentChatStatus === "DEV-LIVE 回复" || ui?.agentChatStatus === "已回复" || ui?.completedMessageVisible === true;
|
||
const uiFailed = ui?.failedMessageVisible === true || blockedCodeAgentUiLabels.has(ui?.agentChatStatus);
|
||
const evidence = inspectSanitizedCompletionEvidence(responseSummary);
|
||
const backendCompleted =
|
||
httpOk &&
|
||
responseSummary?.status === "completed" &&
|
||
responseSummary?.hasReply === true &&
|
||
responseSummary.error === null &&
|
||
evidence.ok;
|
||
|
||
if (backendCompleted && uiCompleted && !uiFailed) {
|
||
return {
|
||
status: "pass",
|
||
blocker: null,
|
||
reason: "completed Code Agent payload over HTTP 2xx with real provider/backend/model/trace/reply evidence and completed UI state"
|
||
};
|
||
}
|
||
|
||
if (runtimeBlock.blocked) {
|
||
return runtimeBlock;
|
||
}
|
||
|
||
if (providerBlock.blocked) {
|
||
return {
|
||
status: "blocked",
|
||
blocker: providerBlock.blocker,
|
||
reason: providerBlock.reason,
|
||
...(providerBlock.providerStatus != null ? { providerStatus: providerBlock.providerStatus } : {})
|
||
};
|
||
}
|
||
|
||
if (responseSummary?.status === "completed") {
|
||
return {
|
||
status: "blocked",
|
||
blocker: "untrusted-completion",
|
||
reason: `Code Agent completed-looking payload was not paired with HTTP success, completed UI, and real provider evidence: ${evidence.reason}`
|
||
};
|
||
}
|
||
|
||
return {
|
||
status: "blocked",
|
||
blocker: "runtime",
|
||
reason: "Code Agent browser journey did not produce a completed non-error response"
|
||
};
|
||
}
|
||
|
||
export function classifyCodeAgentRuntimeBlock(payload, { httpStatus = null } = {}) {
|
||
const errorCode = stringOrNull(payload?.error?.code);
|
||
const blockerCode = stringOrNull(payload?.blocker?.code ?? payload?.error?.blocker?.code);
|
||
const layer = stringOrNull(payload?.error?.layer ?? payload?.blocker?.layer ?? payload?.error?.blocker?.layer);
|
||
const category = stringOrNull(payload?.error?.category ?? payload?.blocker?.category ?? payload?.error?.blocker?.category);
|
||
const payloadStatus = stringOrNull(payload?.status);
|
||
|
||
if (["skill_cli_api_base_missing"].includes(errorCode ?? blockerCode)) {
|
||
return {
|
||
blocked: true,
|
||
status: "blocked",
|
||
blocker: "needs-config",
|
||
category: "needs_config",
|
||
layer,
|
||
reason: "M3 Skill CLI is missing a safe HWLAB API base URL contract; do not show generic send failure."
|
||
};
|
||
}
|
||
|
||
if (["hwlab_api_unavailable"].includes(errorCode ?? blockerCode)) {
|
||
return {
|
||
blocked: true,
|
||
status: "blocked",
|
||
blocker: "retryable",
|
||
category: "retryable",
|
||
layer,
|
||
reason: "HWLAB API is unavailable; the UI should show retryable blocker semantics."
|
||
};
|
||
}
|
||
|
||
if (["text_chat_only_fallback"].includes(errorCode ?? blockerCode) || category === "fallback") {
|
||
return {
|
||
blocked: true,
|
||
status: "blocked",
|
||
blocker: "text-chat-only-fallback",
|
||
category: "fallback",
|
||
layer,
|
||
reason: "Code Agent response is still text chat fallback and does not satisfy runner/session/tool control."
|
||
};
|
||
}
|
||
|
||
if (["m3_readiness_blocked"].includes(errorCode ?? blockerCode) || layer === "m3-readiness") {
|
||
return {
|
||
blocked: true,
|
||
status: "blocked",
|
||
blocker: "capability-unavailable",
|
||
category: category ?? "capability_unavailable",
|
||
layer,
|
||
reason: "M3 readiness is blocked; the UI must not treat this as generic send failure or completed control."
|
||
};
|
||
}
|
||
|
||
if (errorCode === "session_busy") {
|
||
return {
|
||
blocked: true,
|
||
status: "blocked",
|
||
blocker: "runner-busy",
|
||
category: "runner_busy",
|
||
reason: "Code Agent read-only runner session is already busy; the UI must keep the input retryable."
|
||
};
|
||
}
|
||
|
||
if (["session_expired", "session_reuse_conflict", "session_failed", "session_interrupted", "session_canceled", "session_timeout", "session_error"].includes(errorCode)) {
|
||
return {
|
||
blocked: true,
|
||
status: "blocked",
|
||
blocker: "session-blocked",
|
||
category: "session_blocked",
|
||
reason: `Code Agent session is blocked by ${errorCode}; fallback must not be reported as a Codex session.`
|
||
};
|
||
}
|
||
|
||
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(errorCode)) {
|
||
return {
|
||
blocked: true,
|
||
status: "blocked",
|
||
blocker: "runner-blocked",
|
||
category: "runner_blocked",
|
||
reason: `Code Agent read-only runner returned ${errorCode}; this is blocked runner evidence, not a completed fallback.`
|
||
};
|
||
}
|
||
|
||
if (["invalid_params", "parse_error", "request_failed", "cloud_api_proxy_failed", "upstream_unavailable"].includes(errorCode)) {
|
||
return {
|
||
blocked: true,
|
||
status: "blocked",
|
||
blocker: "api-error",
|
||
category: "api_error",
|
||
reason: `Code Agent API returned ${errorCode}; the request is retryable and must remain distinguishable from provider failure.`
|
||
};
|
||
}
|
||
|
||
if (payloadStatus === "failed" && errorCode && errorCode !== "provider_unavailable") {
|
||
return {
|
||
blocked: true,
|
||
status: "blocked",
|
||
blocker: "api-error",
|
||
category: "api_error",
|
||
reason: `Code Agent returned failed status with ${errorCode}.`
|
||
};
|
||
}
|
||
|
||
return { blocked: false };
|
||
}
|
||
|
||
export function classifyCodeAgentBrowserFailure(error, { traceId = null } = {}) {
|
||
const message = error instanceof Error ? error.message : String(error ?? "");
|
||
const observedTraceId = traceId ?? error?.traceId ?? null;
|
||
if (error?.userMessage) {
|
||
return {
|
||
status: "blocked",
|
||
blocker: blockerForStructuredCategory(error.category, error.code),
|
||
category: error.category ?? "runner_blocked",
|
||
chineseSummary: `${error.userMessage}${observedTraceId ? `;traceId=${observedTraceId}` : ""}`,
|
||
reason: message
|
||
};
|
||
}
|
||
if (timeoutPattern.test(message)) {
|
||
return {
|
||
status: "blocked",
|
||
blocker: "timeout",
|
||
category: "timeout",
|
||
chineseSummary: `超时:Code Agent 在配置的活动时间边界内没有新事件${observedTraceId ? `;traceId=${observedTraceId}` : ""}。`,
|
||
reason: message
|
||
};
|
||
}
|
||
if (/session[_ -]?busy|runner busy|already busy/iu.test(message)) {
|
||
return {
|
||
status: "blocked",
|
||
blocker: "runner-busy",
|
||
category: "runner_busy",
|
||
chineseSummary: `Runner 忙碌:Code Agent 正在处理上一轮请求,可稍后重试${observedTraceId ? `;traceId=${observedTraceId}` : ""}。`,
|
||
reason: message
|
||
};
|
||
}
|
||
if (/session[_ -]?(expired|blocked|reuse_conflict)|conversation.*session/iu.test(message)) {
|
||
return {
|
||
status: "blocked",
|
||
blocker: "session-blocked",
|
||
category: "session_blocked",
|
||
chineseSummary: `Session 受阻:Code Agent 会话无法复用或已过期${observedTraceId ? `;traceId=${observedTraceId}` : ""}。`,
|
||
reason: message
|
||
};
|
||
}
|
||
if (/provider|upstream|openai|codex|502|503|504|429|provider_unavailable/iu.test(message)) {
|
||
return {
|
||
status: "blocked",
|
||
blocker: "provider-upstream",
|
||
category: "provider",
|
||
chineseSummary: `Provider 故障:Code Agent 上游或后端返回失败${observedTraceId ? `;traceId=${observedTraceId}` : ""}。`,
|
||
reason: message
|
||
};
|
||
}
|
||
if (/api|http \d{3}|invalid_params|parse_error|request_failed|cloud_api_proxy_failed/iu.test(message)) {
|
||
return {
|
||
status: "blocked",
|
||
blocker: "api-error",
|
||
category: "api_error",
|
||
chineseSummary: `API 错误:Code Agent 接口返回失败,输入应保留可重试${observedTraceId ? `;traceId=${observedTraceId}` : ""}。`,
|
||
reason: message
|
||
};
|
||
}
|
||
return {
|
||
status: "blocked",
|
||
blocker: "browser-runtime",
|
||
category: "browser",
|
||
chineseSummary: `浏览器故障:Workbench 浏览器链路未完成${observedTraceId ? `;traceId=${observedTraceId}` : ""}。`,
|
||
reason: message || "unknown browser error"
|
||
};
|
||
}
|
||
|
||
function blockerForStructuredCategory(category, code) {
|
||
if (category === "needs_config") return "needs-config";
|
||
if (category === "security_blocked" || code === "security_blocked") return "security-blocked";
|
||
if (category === "fallback" || code === "text_chat_only_fallback") return "text-chat-only-fallback";
|
||
if (category === "capability_unavailable") return "capability-unavailable";
|
||
if (category === "retryable") return "retryable";
|
||
if (category === "runner_busy") return "runner-busy";
|
||
if (category === "session_blocked") return "session-blocked";
|
||
if (category === "timeout") return "timeout";
|
||
return "runtime";
|
||
}
|
||
|
||
export function inspectRealCompletionEvidence(payload) {
|
||
const provider = String(payload?.provider ?? "").trim().toLowerCase();
|
||
const backend = String(payload?.backend ?? "").trim().toLowerCase();
|
||
const missing = [];
|
||
if (!payload?.conversationId && !payload?.sessionId) missing.push("conversationId");
|
||
if (!payload?.traceId) missing.push("traceId");
|
||
if (!payload?.messageId) missing.push("messageId");
|
||
if (!payload?.provider) missing.push("provider");
|
||
if (!payload?.model) missing.push("model");
|
||
if (!payload?.backend) missing.push("backend");
|
||
if (missing.length > 0) {
|
||
return { ok: false, reason: `missing ${missing.join(",")}` };
|
||
}
|
||
if (!trustedLiveProviders.has(provider)) {
|
||
return { ok: false, reason: `provider ${payload.provider} is not an accepted real provider` };
|
||
}
|
||
if (provider === "openai-responses" && payload?.capabilityLevel && payload.capabilityLevel !== "text-chat-only") {
|
||
return { ok: false, reason: "openai-responses cannot claim runner capabilityLevel" };
|
||
}
|
||
if (provider === "codex-readonly-runner" && payload?.longLivedSessionGate?.status === "pass") {
|
||
return { ok: false, reason: "read-only runner cannot claim long-lived session gate pass" };
|
||
}
|
||
if (provider === "codex-readonly-runner") {
|
||
return { ok: false, reason: "controlled read-only runner is partial capability only and cannot be reported as full Code Agent completion" };
|
||
}
|
||
if (provider === "openai-responses" && payload?.longLivedSessionGate?.status !== "pass") {
|
||
return { ok: false, reason: "OpenAI Responses fallback is text-chat-only and cannot be reported as full Code Agent completion" };
|
||
}
|
||
if (provider === "codex-stdio" && payload?.longLivedSessionGate?.status !== "pass") {
|
||
return { ok: false, reason: "codex-stdio completion is missing long-lived session gate pass" };
|
||
}
|
||
if (untrustedProviderPattern.test(`${provider} ${backend}`)) {
|
||
return { ok: false, reason: `provider/backend looks non-real: ${payload.provider}/${payload.backend}` };
|
||
}
|
||
return { ok: true, reason: "provider/model/trace/conversation evidence present" };
|
||
}
|
||
|
||
export function normalizeCodeAgentError(error) {
|
||
if (!error || typeof error !== "object") return null;
|
||
return {
|
||
code: stringOrNull(error.code),
|
||
layer: stringOrNull(error.layer),
|
||
category: stringOrNull(error.category),
|
||
retryable: error.retryable === true,
|
||
userMessage: stringOrNull(error.userMessage),
|
||
traceId: stringOrNull(error.traceId),
|
||
route: stringOrNull(error.route),
|
||
toolName: stringOrNull(error.toolName),
|
||
blocker: summarizeBlocker(error.blocker),
|
||
missingConfig: Array.isArray(error.missingConfig) ? error.missingConfig.filter((item) => typeof item === "string") : [],
|
||
missingEnv: Array.isArray(error.missingEnv) ? error.missingEnv.filter((item) => typeof item === "string") : [],
|
||
missingCommands: Array.isArray(error.missingCommands) ? error.missingCommands.filter((item) => typeof item === "string") : [],
|
||
providerStatus: numericStatus(error.providerStatus)
|
||
};
|
||
}
|
||
|
||
export function isHttpNon2xxStatus(value) {
|
||
const status = numericStatus(value);
|
||
return status !== null && (status < 200 || status > 299);
|
||
}
|
||
|
||
function inspectSanitizedCompletionEvidence(summary) {
|
||
const provider = String(summary?.provider ?? "").trim().toLowerCase();
|
||
const backend = String(summary?.backend ?? "").trim().toLowerCase();
|
||
const missing = [];
|
||
if (!summary?.provider) missing.push("provider");
|
||
if (!summary?.model) missing.push("model");
|
||
if (!summary?.backend) missing.push("backend");
|
||
if (!summary?.traceId) missing.push("traceId");
|
||
if (summary?.hasReply !== true) missing.push("reply");
|
||
if (missing.length > 0) {
|
||
return { ok: false, reason: `missing ${missing.join(",")}` };
|
||
}
|
||
if (!trustedLiveProviders.has(provider)) {
|
||
return { ok: false, reason: `provider ${summary.provider} is not an accepted real provider` };
|
||
}
|
||
if (untrustedProviderPattern.test(`${provider} ${backend}`)) {
|
||
return { ok: false, reason: `provider/backend looks non-real: ${summary.provider}/${summary.backend}` };
|
||
}
|
||
return { ok: true, reason: "real provider/backend/model/trace/reply evidence present" };
|
||
}
|
||
|
||
function summarizeRunner(payload) {
|
||
if (!payload?.runner || typeof payload.runner !== "object") return null;
|
||
return {
|
||
kind: stringOrNull(payload.runner.kind),
|
||
backend: stringOrNull(payload.runner.backend),
|
||
workspace: stringOrNull(payload.runner.workspace),
|
||
sandbox: stringOrNull(payload.runner.sandbox),
|
||
session: stringOrNull(payload.runner.session),
|
||
sessionMode: stringOrNull(payload.runner.sessionMode),
|
||
sessionId: stringOrNull(payload.runner.sessionId),
|
||
implementationType: stringOrNull(payload.runner.implementationType),
|
||
codexStdio: payload.runner.codexStdio === true,
|
||
writeCapable: payload.runner.writeCapable === true,
|
||
durableSession: payload.runner.durableSession === true,
|
||
longLivedSession: payload.runner.longLivedSession === true,
|
||
readOnly: payload.runner.readOnly === true,
|
||
capabilityLevel: stringOrNull(payload.runner.capabilityLevel),
|
||
runnerLimitations: summarizeStringList(payload.runner.runnerLimitations)
|
||
};
|
||
}
|
||
|
||
function summarizeToolCalls(value) {
|
||
if (!Array.isArray(value)) return [];
|
||
return value.map((tool) => ({
|
||
name: stringOrNull(tool?.name),
|
||
type: stringOrNull(tool?.type),
|
||
status: stringOrNull(tool?.status),
|
||
route: stringOrNull(tool?.route),
|
||
capabilityLevel: stringOrNull(tool?.capabilityLevel),
|
||
blocker: summarizeBlocker(tool?.blocker),
|
||
exitCode: numericStatus(tool?.exitCode),
|
||
outputTruncated: tool?.outputTruncated === true
|
||
}));
|
||
}
|
||
|
||
function summarizeSkills(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
status: stringOrNull(value.status),
|
||
count: typeof value.count === "number" ? value.count : Array.isArray(value.items) ? value.items.length : null,
|
||
totalCount: typeof value.totalCount === "number" ? value.totalCount : null,
|
||
blockers: Array.isArray(value.blockers)
|
||
? value.blockers.map((blocker) => ({
|
||
code: stringOrNull(blocker.code),
|
||
sourceIssue: stringOrNull(blocker.sourceIssue)
|
||
}))
|
||
: []
|
||
};
|
||
}
|
||
|
||
function summarizeRunnerTrace(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
runnerKind: stringOrNull(value.runnerKind),
|
||
route: stringOrNull(value.route),
|
||
capabilityLevel: stringOrNull(value.capabilityLevel),
|
||
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,
|
||
outputTruncated: value.outputTruncated === true,
|
||
valuesPrinted: value.valuesPrinted === true
|
||
};
|
||
}
|
||
|
||
function summarizeBlocker(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
code: stringOrNull(value.code),
|
||
layer: stringOrNull(value.layer),
|
||
category: stringOrNull(value.category),
|
||
retryable: value.retryable === true,
|
||
userMessage: stringOrNull(value.userMessage),
|
||
route: stringOrNull(value.route),
|
||
toolName: stringOrNull(value.toolName)
|
||
};
|
||
}
|
||
|
||
function summarizeSessionReuse(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
conversationId: stringOrNull(value.conversationId),
|
||
sessionId: stringOrNull(value.sessionId),
|
||
mapped: value.mapped === true,
|
||
reused: value.reused === true,
|
||
turn: typeof value.turn === "number" ? value.turn : null,
|
||
previousTurns: typeof value.previousTurns === "number" ? value.previousTurns : null,
|
||
workspace: stringOrNull(value.workspace),
|
||
status: stringOrNull(value.status),
|
||
idleTimeoutMs: typeof value.idleTimeoutMs === "number" ? value.idleTimeoutMs : null,
|
||
lastTraceId: stringOrNull(value.lastTraceId)
|
||
};
|
||
}
|
||
|
||
function summarizeSession(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
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),
|
||
capabilityLevel: stringOrNull(value.capabilityLevel),
|
||
implementationType: stringOrNull(value.implementationType),
|
||
createdAt: stringOrNull(value.createdAt),
|
||
updatedAt: stringOrNull(value.updatedAt),
|
||
idleTimeoutMs: typeof value.idleTimeoutMs === "number" ? value.idleTimeoutMs : null,
|
||
lastTraceId: stringOrNull(value.lastTraceId),
|
||
turn: typeof value.turn === "number" ? value.turn : null,
|
||
longLivedSession: value.longLivedSession === true,
|
||
codexStdio: value.codexStdio === true,
|
||
writeCapable: value.writeCapable === true,
|
||
secretMaterialStored: value.secretMaterialStored === true
|
||
};
|
||
}
|
||
|
||
function summarizeConversationFacts(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
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),
|
||
workspace: stringOrNull(value.workspace),
|
||
sandbox: stringOrNull(value.sandbox),
|
||
turnCount: typeof value.turnCount === "number" ? value.turnCount : null,
|
||
latestTraceId: stringOrNull(value.latestTraceId),
|
||
traceIds: Array.isArray(value.traceIds) ? value.traceIds.map(stringOrNull).filter(Boolean).slice(-6) : [],
|
||
latestSkills: value.latestSkills && typeof value.latestSkills === "object"
|
||
? {
|
||
status: stringOrNull(value.latestSkills.status),
|
||
count: typeof value.latestSkills.count === "number" ? value.latestSkills.count : null,
|
||
totalCount: typeof value.latestSkills.totalCount === "number" ? value.latestSkills.totalCount : null,
|
||
names: Array.isArray(value.latestSkills.names) ? value.latestSkills.names.map(stringOrNull).filter(Boolean).slice(0, 8) : []
|
||
}
|
||
: null,
|
||
recentToolCalls: Array.isArray(value.recentToolCalls)
|
||
? value.recentToolCalls.map((tool) => ({
|
||
name: stringOrNull(tool?.name),
|
||
status: stringOrNull(tool?.status)
|
||
})).filter((tool) => tool.name || tool.status).slice(-8)
|
||
: [],
|
||
valuesRedacted: value.valuesRedacted === true,
|
||
secretMaterialStored: value.secretMaterialStored === true
|
||
};
|
||
}
|
||
|
||
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 {
|
||
status: stringOrNull(value.status),
|
||
canStartLongLivedCodexStdio: value.canStartLongLivedCodexStdio === true,
|
||
currentImplementation: stringOrNull(value.currentImplementation),
|
||
implementationRequired: stringOrNull(value.implementationRequired),
|
||
binaryOnPath: value.binaryOnPath === true,
|
||
binary: value.binary && typeof value.binary === "object"
|
||
? {
|
||
status: stringOrNull(value.binary.status),
|
||
present: value.binary.present === true,
|
||
command: stringOrNull(value.binary.command),
|
||
version: stringOrNull(value.binary.version),
|
||
versionDetected: value.binary.versionDetected === true
|
||
}
|
||
: null,
|
||
runtimeContract: value.runtimeContract && typeof value.runtimeContract === "object"
|
||
? {
|
||
status: stringOrNull(value.runtimeContract.status),
|
||
ready: value.runtimeContract.ready === true,
|
||
binaryStatus: stringOrNull(value.runtimeContract.binary?.status),
|
||
stdioProtocolStatus: stringOrNull(value.runtimeContract.stdioProtocol?.status),
|
||
lifecycleStatus: stringOrNull(value.runtimeContract.lifecycleSupervisor?.status),
|
||
workspaceStatus: stringOrNull(value.runtimeContract.workspaceMount?.status),
|
||
tokenBoundaryStatus: stringOrNull(value.runtimeContract.tokenBoundary?.status),
|
||
cancelReapTraceStatus: stringOrNull(value.runtimeContract.cancelReapTraceReadiness?.status)
|
||
}
|
||
: null,
|
||
workspace: stringOrNull(value.workspace),
|
||
sandbox: stringOrNull(value.sandbox),
|
||
ready: value.ready === true,
|
||
sessionLifecycle: value.sessionLifecycle && typeof value.sessionLifecycle === "object"
|
||
? {
|
||
create: value.sessionLifecycle.create === true,
|
||
reuse: value.sessionLifecycle.reuse === true,
|
||
cancel: value.sessionLifecycle.cancel === true,
|
||
reap: value.sessionLifecycle.reap === true,
|
||
idleTimeout: value.sessionLifecycle.idleTimeout === true,
|
||
traceCapture: value.sessionLifecycle.traceCapture === true,
|
||
idleTimeoutMs: typeof value.sessionLifecycle.idleTimeoutMs === "number" ? value.sessionLifecycle.idleTimeoutMs : null
|
||
}
|
||
: null,
|
||
blockers: Array.isArray(value.blockers)
|
||
? value.blockers.map((blocker) => ({
|
||
code: stringOrNull(blocker.code),
|
||
sourceIssue: stringOrNull(blocker.sourceIssue)
|
||
}))
|
||
: []
|
||
};
|
||
}
|
||
|
||
function summarizeLongLivedSessionGate(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
status: stringOrNull(value.status),
|
||
pass: value.pass === true,
|
||
requiredCapability: stringOrNull(value.requiredCapability),
|
||
currentCapability: stringOrNull(value.currentCapability),
|
||
sessionId: stringOrNull(value.sessionId),
|
||
sessionStatus: stringOrNull(value.sessionStatus),
|
||
runnerKind: stringOrNull(value.runnerKind),
|
||
provider: stringOrNull(value.provider),
|
||
blockers: Array.isArray(value.blockers)
|
||
? value.blockers.map((blocker) => ({
|
||
code: stringOrNull(blocker.code),
|
||
sourceIssue: stringOrNull(blocker.sourceIssue)
|
||
}))
|
||
: []
|
||
};
|
||
}
|
||
|
||
function summarizeStringList(value) {
|
||
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
||
}
|
||
|
||
function stringOrNull(value) {
|
||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||
}
|
||
|
||
function numericStatus(value) {
|
||
if (typeof value !== "number" || !Number.isInteger(value)) return null;
|
||
return value;
|
||
}
|