补齐 Code Agent 长会话生命周期控制
This commit is contained in:
@@ -284,7 +284,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
const failedAt = nowIso(options.now);
|
||||
const payload = {
|
||||
...base,
|
||||
status: "failed",
|
||||
status: chatStatusForError(error),
|
||||
updatedAt: failedAt,
|
||||
error: normalizeChatError(error, {
|
||||
traceId,
|
||||
@@ -406,14 +406,14 @@ export function validateCodeAgentChatSchema(payload) {
|
||||
throw new Error(`code agent chat response missing ${field}`);
|
||||
}
|
||||
}
|
||||
if (!["running", "completed", "failed"].includes(payload.status)) {
|
||||
if (!["running", "completed", "failed", "timeout", "error", "canceled"].includes(payload.status)) {
|
||||
throw new Error(`code agent chat response has invalid status ${JSON.stringify(payload.status)}`);
|
||||
}
|
||||
if (Number.isNaN(Date.parse(payload.createdAt)) || Number.isNaN(Date.parse(payload.updatedAt))) {
|
||||
throw new Error("code agent chat response timestamps must be RFC 3339 strings");
|
||||
}
|
||||
if (payload.status === "failed" && typeof payload.error?.message !== "string") {
|
||||
throw new Error("failed code agent chat response must include error.message");
|
||||
if (["failed", "timeout", "error", "canceled"].includes(payload.status) && typeof payload.error?.message !== "string") {
|
||||
throw new Error("non-completed code agent chat response must include error.message");
|
||||
}
|
||||
if (payload.status === "completed" && (typeof payload.reply?.content !== "string" || !payload.reply.content.trim())) {
|
||||
throw new Error("completed code agent chat response must include non-empty reply.content");
|
||||
@@ -750,7 +750,8 @@ function codexStdioSessionRegistrySummary(codexStdio = {}) {
|
||||
sessionCount: codexStdio.sessionLifecycle?.activeSessions ?? codexStdio.lifecycleSupervisor?.activeSessions ?? null,
|
||||
maxSessions: codexStdio.sessionLifecycle?.maxSessions ?? codexStdio.lifecycleSupervisor?.maxSessions ?? null,
|
||||
idleTimeoutMs: codexStdio.sessionLifecycle?.idleTimeoutMs ?? codexStdio.lifecycleSupervisor?.idleTimeoutMs ?? null,
|
||||
statuses: ["creating", "ready", "busy", "idle", "interrupted", "expired", "failed"],
|
||||
statuses: ["creating", "ready", "busy", "idle", "timeout", "error", "canceled", "interrupted", "expired", "failed"],
|
||||
statusCounts: codexStdio.statusCounts ?? null,
|
||||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||||
recentSessions: Array.isArray(codexStdio.recentSessions) ? codexStdio.recentSessions : [],
|
||||
longLivedCodexStdio: true,
|
||||
@@ -1918,6 +1919,15 @@ function normalizeUserMessage(value) {
|
||||
return message;
|
||||
}
|
||||
|
||||
function chatStatusForError(error) {
|
||||
const code = String(error?.code ?? "");
|
||||
if (code === "codex_stdio_canceled" || code === "session_canceled") return "canceled";
|
||||
if (code === "codex_stdio_timeout" || code === "provider_timeout" || code === "session_timeout") return "timeout";
|
||||
if (code === "session_error") return "error";
|
||||
if (/timeout|timed out|超时/iu.test(String(error?.message ?? ""))) return "timeout";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function conversationFactsForPrompt(sessionRegistry, conversationId) {
|
||||
if (!sessionRegistry || typeof sessionRegistry.getConversationFacts !== "function") return null;
|
||||
try {
|
||||
@@ -2104,6 +2114,24 @@ function errorTaxonomy(code, error = {}) {
|
||||
retryable: true,
|
||||
userMessage: "Code Agent session 已中断,可重新发送建立新的 session。"
|
||||
},
|
||||
session_canceled: {
|
||||
layer: "session",
|
||||
category: "session_blocked",
|
||||
retryable: true,
|
||||
userMessage: "Code Agent session 已取消,输入和 trace 已保留,可重试上一条消息。"
|
||||
},
|
||||
session_timeout: {
|
||||
layer: "session",
|
||||
category: "timeout",
|
||||
retryable: true,
|
||||
userMessage: "Code Agent session 已超时,输入和 trace 已保留,可重试上一条消息。"
|
||||
},
|
||||
session_error: {
|
||||
layer: "session",
|
||||
category: "session_blocked",
|
||||
retryable: true,
|
||||
userMessage: "Code Agent session 返回错误,输入和 trace 已保留,可重试上一条消息。"
|
||||
},
|
||||
runner_unavailable: {
|
||||
layer: "runner",
|
||||
category: "runner_blocked",
|
||||
@@ -2170,6 +2198,18 @@ function errorTaxonomy(code, error = {}) {
|
||||
retryable: true,
|
||||
userMessage: "Codex stdio 未返回有效回复,可稍后重试。"
|
||||
},
|
||||
codex_stdio_timeout: {
|
||||
layer: "runner",
|
||||
category: "timeout",
|
||||
retryable: true,
|
||||
userMessage: "Codex stdio runner 响应超时,输入、sessionId 和 traceId 已保留,可重试。"
|
||||
},
|
||||
codex_stdio_canceled: {
|
||||
layer: "runner",
|
||||
category: "canceled",
|
||||
retryable: true,
|
||||
userMessage: "本次 Codex stdio 请求已取消;输入、sessionId 和 traceId 已保留,可重试上一条消息。"
|
||||
},
|
||||
codex_stdio_command_probe_failed: {
|
||||
layer: "runner",
|
||||
category: "runner_blocked",
|
||||
|
||||
@@ -10,6 +10,9 @@ export const CODE_AGENT_SESSION_STATUSES = Object.freeze([
|
||||
"ready",
|
||||
"busy",
|
||||
"idle",
|
||||
"timeout",
|
||||
"error",
|
||||
"canceled",
|
||||
"interrupted",
|
||||
"expired",
|
||||
"failed"
|
||||
@@ -74,7 +77,7 @@ export function createCodeAgentSessionRegistry(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
if (session && ["failed", "interrupted"].includes(session.status)) {
|
||||
if (session && ["failed", "interrupted", "timeout", "error", "canceled"].includes(session.status)) {
|
||||
session.updatedAt = timestamp;
|
||||
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
|
||||
return blockedAcquire({
|
||||
|
||||
@@ -65,7 +65,11 @@ export function createCodeAgentTraceStore(options = {}) {
|
||||
}
|
||||
trace.updatedAt = normalized.createdAt;
|
||||
if (normalized.terminal === true) {
|
||||
trace.status = normalized.status === "completed" ? "completed" : normalized.type;
|
||||
trace.status = normalized.status === "completed"
|
||||
? "completed"
|
||||
: normalized.status === "canceled"
|
||||
? "canceled"
|
||||
: normalized.type;
|
||||
trace.finishedAt = normalized.createdAt;
|
||||
} else if (normalized.status === "failed" || normalized.type === "error" || normalized.type === "timeout") {
|
||||
trace.status = normalized.type;
|
||||
|
||||
@@ -23,6 +23,18 @@ export const DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
export const DEFAULT_CODEX_STDIO_MAX_SESSIONS = 64;
|
||||
export const DEFAULT_CODEX_STDIO_COMMAND = "codex";
|
||||
export const DEFAULT_CODEX_STDIO_PROBE_TTL_MS = 30 * 1000;
|
||||
export const CODEX_STDIO_SESSION_STATUSES = Object.freeze([
|
||||
"creating",
|
||||
"ready",
|
||||
"busy",
|
||||
"idle",
|
||||
"timeout",
|
||||
"error",
|
||||
"canceled",
|
||||
"interrupted",
|
||||
"expired",
|
||||
"failed"
|
||||
]);
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
||||
@@ -207,6 +219,8 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
commandProbe: commandProbeState,
|
||||
sessionLifecycle: lifecycle,
|
||||
lifecycleSupervisor: lifecycle,
|
||||
recentSessions: recentSessions(),
|
||||
statusCounts: sessionStatusCounts(),
|
||||
workspaceMount: workspaceContractState(workspaceInfo, sandbox, workspace),
|
||||
cancelReapTraceReadiness: cancelReapTraceState(lifecycle),
|
||||
runtimeContract: runtimeContract({
|
||||
@@ -482,27 +496,47 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
};
|
||||
} catch (error) {
|
||||
closeRpcClient();
|
||||
session = failSession(session.sessionId, {
|
||||
now,
|
||||
traceId,
|
||||
conversationId,
|
||||
reused: session.reused,
|
||||
statusReason: error.code ?? "codex_stdio_failed"
|
||||
}) ?? session;
|
||||
const timeout = /timed out|timeout|超时/iu.test(String(error.message ?? ""));
|
||||
const currentSession = sessions.get(session.sessionId) ?? null;
|
||||
const canceled = currentSession?.status === "canceled";
|
||||
session = canceled
|
||||
? publicSession(currentSession, { conversationId, reused: session.reused })
|
||||
: failSession(session.sessionId, {
|
||||
now,
|
||||
traceId,
|
||||
conversationId,
|
||||
reused: session.reused,
|
||||
status: timeout ? "timeout" : "failed",
|
||||
statusReason: timeout ? "codex_stdio_timeout" : error.code ?? "codex_stdio_failed"
|
||||
}) ?? session;
|
||||
traceRecorder.append({
|
||||
type: timeout ? "timeout" : "error",
|
||||
status: "failed",
|
||||
label: timeout ? "timeout" : `error:${error.code ?? "codex_stdio_failed"}`,
|
||||
errorCode: error.code ?? (timeout ? "codex_stdio_timeout" : "codex_stdio_failed"),
|
||||
message: error.message || "Codex stdio session failed.",
|
||||
type: canceled ? "cancel" : timeout ? "timeout" : "error",
|
||||
status: canceled ? "canceled" : "failed",
|
||||
label: canceled ? "cancel:canceled" : timeout ? "timeout" : `error:${error.code ?? "codex_stdio_failed"}`,
|
||||
errorCode: canceled ? "codex_stdio_canceled" : error.code ?? (timeout ? "codex_stdio_timeout" : "codex_stdio_failed"),
|
||||
message: canceled ? "Codex stdio request was canceled by the user." : error.message || "Codex stdio session failed.",
|
||||
timeoutMs: timeout ? effectiveTimeout(params.timeoutMs) : undefined,
|
||||
sessionId: session.sessionId,
|
||||
sessionStatus: session.status,
|
||||
turn: session.turn,
|
||||
waitingFor: timeout ? "codex-stdio-tool-response" : null,
|
||||
waitingFor: canceled ? "user-retry" : timeout ? "codex-stdio-tool-response" : null,
|
||||
terminal: true
|
||||
});
|
||||
if (canceled) {
|
||||
throw codexStdioError("codex_stdio_canceled", "Codex stdio request was canceled by the user.", {
|
||||
availability,
|
||||
session,
|
||||
runnerTrace: runnerTrace({
|
||||
traceRecorder,
|
||||
traceId,
|
||||
workspace,
|
||||
sandbox,
|
||||
session,
|
||||
startedAt,
|
||||
outputTruncated: false
|
||||
})
|
||||
});
|
||||
}
|
||||
if (error.code && (error.code.startsWith("codex_stdio") || ["skills_unavailable"].includes(error.code))) {
|
||||
error.session = session;
|
||||
error.availability = error.availability ?? describe({ ...params, env, workspace, sandbox });
|
||||
@@ -640,11 +674,11 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
|
||||
if (!session) return null;
|
||||
const timestamp = timestampFor(params.now ?? nowDefault);
|
||||
session.status = "interrupted";
|
||||
session.status = "canceled";
|
||||
session.updatedAt = timestamp;
|
||||
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
|
||||
session.currentTraceId = null;
|
||||
session.statusReason = params.reason ?? "cancelled";
|
||||
session.statusReason = params.reason ?? "user_canceled";
|
||||
if (rpcClient && typeof rpcClient.close === "function") {
|
||||
closeRpcClient();
|
||||
}
|
||||
@@ -823,9 +857,9 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
const effectiveSessionId = mappedSessionId || requestedSessionId || requiredId(idFactory(), "ses");
|
||||
let effectiveSessionId = mappedSessionId || requestedSessionId || requiredId(idFactory(), "ses");
|
||||
let session = sessions.get(effectiveSessionId) ?? null;
|
||||
const reused = Boolean(session);
|
||||
let reused = Boolean(session);
|
||||
|
||||
if (session && sessionExpired(session, timestampMs)) {
|
||||
session.status = "expired";
|
||||
@@ -851,6 +885,23 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
if (session && ["failed", "interrupted", "timeout", "error", "canceled"].includes(session.status) && !requestedSessionId && mappedSessionId) {
|
||||
conversations.delete(conversationId);
|
||||
effectiveSessionId = requiredId(idFactory(), "ses");
|
||||
session = null;
|
||||
reused = false;
|
||||
}
|
||||
|
||||
if (session && ["failed", "interrupted", "timeout", "error", "canceled"].includes(session.status)) {
|
||||
return blockedAcquire({
|
||||
code: `session_${session.status}`,
|
||||
summary: `Codex stdio session ${effectiveSessionId} is ${session.status}; create a new session before retrying.`,
|
||||
session,
|
||||
timestamp,
|
||||
traceId: params.traceId
|
||||
});
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
session = {
|
||||
sessionId: effectiveSessionId,
|
||||
@@ -903,7 +954,7 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
if (!session) return null;
|
||||
const timestamp = timestampFor(params.now ?? nowDefault);
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
session.status = params.status ?? "idle";
|
||||
session.status = CODEX_STDIO_SESSION_STATUSES.includes(params.status) ? params.status : "idle";
|
||||
session.updatedAt = timestamp;
|
||||
session.expiresAt = plusMs(timestampMs, idleTimeoutMs);
|
||||
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
|
||||
@@ -917,7 +968,7 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
}
|
||||
|
||||
function failSession(sessionId, params = {}) {
|
||||
return releaseSession(sessionId, { ...params, status: "failed" });
|
||||
return releaseSession(sessionId, { ...params, status: params.status ?? "failed" });
|
||||
}
|
||||
|
||||
function pruneSessions() {
|
||||
@@ -936,6 +987,22 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
return [...sessions.values()].filter((session) => session.status === "busy").length;
|
||||
}
|
||||
|
||||
function recentSessions() {
|
||||
return [...sessions.values()]
|
||||
.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)))
|
||||
.slice(0, 10)
|
||||
.map((session) => publicSession(session));
|
||||
}
|
||||
|
||||
function sessionStatusCounts() {
|
||||
const counts = {};
|
||||
for (const status of CODEX_STDIO_SESSION_STATUSES) counts[status] = 0;
|
||||
for (const session of sessions.values()) {
|
||||
counts[session.status] = (counts[session.status] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
return {
|
||||
describe,
|
||||
probe,
|
||||
|
||||
+186
-1
@@ -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 { createCodexStdioSessionManager } from "./codex-stdio-session.mjs";
|
||||
import { buildGateDiagnosticsRows } from "./gate-diagnostics.mjs";
|
||||
import {
|
||||
applyRuntimeDbReadinessLayers,
|
||||
@@ -83,6 +84,7 @@ export function createCloudApiServer(options = {}) {
|
||||
ensureCodeAgentRuntimeBase(env);
|
||||
const sessionRegistry = options.sessionRegistry || createCodeAgentSessionRegistry();
|
||||
const traceStore = options.traceStore || defaultCodeAgentTraceStore;
|
||||
const codexStdioManager = options.codexStdioManager || createCodexStdioSessionManager({ traceStore });
|
||||
const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env });
|
||||
const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({
|
||||
staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000),
|
||||
@@ -90,7 +92,7 @@ export function createCloudApiServer(options = {}) {
|
||||
});
|
||||
return createServer(async (request, response) => {
|
||||
try {
|
||||
await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, sessionRegistry, traceStore });
|
||||
await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, sessionRegistry, traceStore, codexStdioManager });
|
||||
} catch (error) {
|
||||
sendJson(response, 500, {
|
||||
error: {
|
||||
@@ -330,6 +332,11 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/v1/agent/chat/cancel") {
|
||||
await handleCodeAgentCancelHttp(request, response, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/trace/")) {
|
||||
await handleCodeAgentTraceHttp(request, response, url, options);
|
||||
return;
|
||||
@@ -1258,6 +1265,169 @@ async function handleCodeAgentChatHttp(request, response, options) {
|
||||
sendJson(response, payload.status === "failed" && payload.error?.code === "invalid_params" ? 400 : 200, payload);
|
||||
}
|
||||
|
||||
async function handleCodeAgentCancelHttp(request, response, options) {
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let params = {};
|
||||
|
||||
try {
|
||||
params = body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
sendJson(response, 400, {
|
||||
...createCodeAgentErrorPayload({
|
||||
code: "parse_error",
|
||||
message: "Invalid JSON body",
|
||||
reason: error.message,
|
||||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||||
layer: "api",
|
||||
retryable: true
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
sendJson(response, 400, {
|
||||
...createCodeAgentErrorPayload({
|
||||
code: "invalid_params",
|
||||
message: "Code Agent cancel body must be a JSON object",
|
||||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||||
layer: "api",
|
||||
retryable: true
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId);
|
||||
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
||||
const snapshot = traceId ? traceStore.snapshot(traceId) : null;
|
||||
const sessionId = safeSessionId(params.sessionId) || safeSessionId(snapshot?.sessionId);
|
||||
const conversationId = safeConversationId(params.conversationId);
|
||||
const manager = options.codexStdioManager;
|
||||
|
||||
if (!traceId) {
|
||||
sendJson(response, 400, cancelBlockedPayload({
|
||||
code: "cancel_trace_missing",
|
||||
message: "traceId is required to cancel the current Code Agent request.",
|
||||
traceId: "trc_unassigned",
|
||||
conversationId,
|
||||
sessionId
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId || !manager || typeof manager.get !== "function" || typeof manager.cancel !== "function") {
|
||||
traceStore.append(traceId, {
|
||||
type: "cancel",
|
||||
status: "blocked",
|
||||
label: "cancel:not_cancelable",
|
||||
errorCode: "cancel_session_missing",
|
||||
message: "Cancel request did not include a bound Codex stdio sessionId.",
|
||||
waitingFor: "session-binding"
|
||||
});
|
||||
sendJson(response, 409, cancelBlockedPayload({
|
||||
code: "cancel_session_missing",
|
||||
message: "当前请求尚未暴露可取消的 Codex stdio sessionId;页面不能只隐藏 UI,已保留输入和 trace。",
|
||||
traceId,
|
||||
conversationId,
|
||||
sessionId: null,
|
||||
runnerTrace: traceStore.snapshot(traceId)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSession = manager.get(sessionId, { conversationId }) ?? null;
|
||||
if (!currentSession || !["busy", "creating"].includes(currentSession.status)) {
|
||||
traceStore.append(traceId, {
|
||||
type: "cancel",
|
||||
status: "blocked",
|
||||
label: "cancel:not_in_flight",
|
||||
errorCode: "cancel_not_in_flight",
|
||||
message: `Session ${sessionId} is not an in-flight Codex stdio request.`,
|
||||
sessionId,
|
||||
sessionStatus: currentSession?.status ?? "missing"
|
||||
});
|
||||
sendJson(response, 409, cancelBlockedPayload({
|
||||
code: currentSession ? "cancel_not_in_flight" : "cancel_session_not_found",
|
||||
message: currentSession
|
||||
? `当前 session 状态为 ${currentSession.status},没有可取消的 in-flight Codex stdio 请求。`
|
||||
: `没有找到 sessionId=${sessionId} 的 Codex stdio session。`,
|
||||
traceId,
|
||||
conversationId,
|
||||
sessionId,
|
||||
session: currentSession,
|
||||
runnerTrace: traceStore.snapshot(traceId)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const canceledSession = manager.cancel(sessionId, {
|
||||
traceId,
|
||||
conversationId,
|
||||
reason: "user_cancel"
|
||||
});
|
||||
traceStore.append(traceId, {
|
||||
type: "cancel",
|
||||
status: "canceled",
|
||||
label: "cancel:canceled",
|
||||
message: "User canceled the current Codex stdio request.",
|
||||
sessionId,
|
||||
sessionStatus: canceledSession?.status ?? "canceled",
|
||||
waitingFor: "user-retry",
|
||||
terminal: true
|
||||
});
|
||||
const runnerTrace = traceStore.snapshot(traceId);
|
||||
sendJson(response, 200, {
|
||||
accepted: true,
|
||||
canceled: true,
|
||||
status: "canceled",
|
||||
conversationId: conversationId ?? canceledSession?.conversationId ?? null,
|
||||
sessionId,
|
||||
traceId,
|
||||
session: canceledSession,
|
||||
runnerTrace,
|
||||
lastTraceEvent: runnerTrace.lastEvent,
|
||||
retryable: true,
|
||||
userMessage: "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。"
|
||||
});
|
||||
}
|
||||
|
||||
function cancelBlockedPayload({ code, message, traceId, conversationId = null, sessionId = null, session = null, runnerTrace = null }) {
|
||||
return {
|
||||
accepted: false,
|
||||
canceled: false,
|
||||
status: "failed",
|
||||
conversationId,
|
||||
sessionId,
|
||||
traceId,
|
||||
session,
|
||||
runnerTrace,
|
||||
lastTraceEvent: runnerTrace?.lastEvent ?? null,
|
||||
error: {
|
||||
code,
|
||||
layer: "session",
|
||||
category: "cancel_blocked",
|
||||
retryable: true,
|
||||
userMessage: message,
|
||||
message,
|
||||
traceId,
|
||||
route: "/v1/agent/chat/cancel",
|
||||
toolName: "codex-stdio.cancel"
|
||||
},
|
||||
blocker: {
|
||||
code,
|
||||
layer: "session",
|
||||
category: "cancel_blocked",
|
||||
retryable: true,
|
||||
summary: message,
|
||||
userMessage: message,
|
||||
traceId,
|
||||
route: "/v1/agent/chat/cancel",
|
||||
toolName: "codex-stdio.cancel"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCodeAgentTraceHttp(request, response, url, options) {
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
const traceId = decodeURIComponent(parts[4] ?? "");
|
||||
@@ -1540,6 +1710,21 @@ function getHeader(request, name) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeTraceId(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^trc_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function safeSessionId(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function safeConversationId(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^cnv_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
|
||||
@@ -9,6 +9,7 @@ import test from "node:test";
|
||||
import { createCloudApiServer } from "./server.mjs";
|
||||
import { validateCodeAgentChatSchema } from "./code-agent-chat.mjs";
|
||||
import { createCodexStdioSessionManager } from "./codex-stdio-session.mjs";
|
||||
import { createCodeAgentTraceStore } from "./code-agent-trace-store.mjs";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
import { classifyCodexRunnerCapability } from "../../scripts/src/code-agent-response-contract.mjs";
|
||||
import {
|
||||
@@ -2170,7 +2171,7 @@ test("cloud api health reports Codex stdio runner facts without readonly limitat
|
||||
assert.equal(payload.codeAgent.sandbox, "workspace-write");
|
||||
assert.equal(payload.codeAgent.sessionMode, "codex-mcp-stdio-long-lived");
|
||||
assert.equal(payload.codeAgent.sessionRegistry.kind, "codex-stdio-session-registry");
|
||||
assert.deepEqual(payload.codeAgent.sessionRegistry.statuses, ["creating", "ready", "busy", "idle", "interrupted", "expired", "failed"]);
|
||||
assert.deepEqual(payload.codeAgent.sessionRegistry.statuses, ["creating", "ready", "busy", "idle", "timeout", "error", "canceled", "interrupted", "expired", "failed"]);
|
||||
assert.equal(payload.codeAgent.capabilityLevel, "long-lived-codex-stdio-session");
|
||||
assert.equal(payload.codeAgent.longLivedSessionGate.status, "pass");
|
||||
assert.equal(payload.readiness.sessionRunner.status, "codex_stdio_ready");
|
||||
@@ -3349,6 +3350,118 @@ test("cloud api /v1/agent/chat exposes prompt trace immediately while Codex stdi
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat/cancel cancels in-flight Codex stdio and keeps retry trace", async () => {
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
const workspace = path.join(fakeCodex.root, "workspace");
|
||||
const codexHome = path.join(fakeCodex.root, "codex-home");
|
||||
await mkdir(workspace, { recursive: true });
|
||||
await mkdir(codexHome, { recursive: true });
|
||||
const traceStore = createCodeAgentTraceStore();
|
||||
let rejectTool = null;
|
||||
let toolStartedResolve = null;
|
||||
const toolStarted = new Promise((resolve) => {
|
||||
toolStartedResolve = resolve;
|
||||
});
|
||||
const manager = createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_server_test_cancel",
|
||||
traceStore,
|
||||
createRpcClient: async () => ({
|
||||
async initialize() {
|
||||
return { tools: ["codex", "codex-reply"] };
|
||||
},
|
||||
async listTools() {
|
||||
return ["codex", "codex-reply"];
|
||||
},
|
||||
async callTool() {
|
||||
toolStartedResolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
rejectTool = reject;
|
||||
});
|
||||
},
|
||||
close() {
|
||||
if (rejectTool) {
|
||||
rejectTool(new Error("closed by user cancel"));
|
||||
rejectTool = null;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
const server = createCloudApiServer({
|
||||
traceStore,
|
||||
codexStdioManager: manager,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_CODEX_SANDBOX: "workspace-write",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
CODEX_HOME: codexHome
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const chatPromise = fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": "trc_server-test-cancel"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-cancel",
|
||||
message: "请执行一个需要等待的 Codex stdio 请求"
|
||||
})
|
||||
});
|
||||
await toolStarted;
|
||||
const cancelResponse = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/cancel`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": "trc_server-test-cancel"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-cancel",
|
||||
sessionId: "ses_server_test_cancel",
|
||||
traceId: "trc_server-test-cancel"
|
||||
})
|
||||
});
|
||||
assert.equal(cancelResponse.status, 200);
|
||||
const cancelPayload = await cancelResponse.json();
|
||||
assert.equal(cancelPayload.status, "canceled");
|
||||
assert.equal(cancelPayload.canceled, true);
|
||||
assert.equal(cancelPayload.sessionId, "ses_server_test_cancel");
|
||||
assert.equal(cancelPayload.session.status, "canceled");
|
||||
assert.equal(cancelPayload.lastTraceEvent.label, "cancel:canceled");
|
||||
|
||||
const chatResponse = await chatPromise;
|
||||
assert.equal(chatResponse.status, 200);
|
||||
const chatPayload = await chatResponse.json();
|
||||
assert.equal(chatPayload.status, "canceled");
|
||||
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.runnerTrace.lastEvent.label, "cancel:canceled");
|
||||
|
||||
const traceResponse = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/trc_server-test-cancel`);
|
||||
assert.equal(traceResponse.status, 200);
|
||||
const tracePayload = await traceResponse.json();
|
||||
assert.equal(tracePayload.status, "canceled");
|
||||
assert.equal(tracePayload.lastEvent.label, "cancel:canceled");
|
||||
assert.ok(tracePayload.events.some((event) => event.label === "tool:codex:started"));
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -2192,6 +2192,9 @@ function defaultTopologyIsM3Only() {
|
||||
|
||||
function trustedRecordGroups({ html, app, styles }) {
|
||||
const source = `${html}\n${app}\n${styles}`;
|
||||
const failedRecordsStayBlocked =
|
||||
/message\.status === "failed" \? "blocked" : "source"/u.test(app) ||
|
||||
/\["failed",\s*"timeout",\s*"canceled",\s*"error"\]\.includes\(message\.status\) \? "blocked" : "source"/u.test(app);
|
||||
return (
|
||||
/id=["']records-list["']/u.test(html) &&
|
||||
/function\s+renderRecords\s*\(/u.test(app) &&
|
||||
@@ -2219,7 +2222,7 @@ function trustedRecordGroups({ html, app, styles }) {
|
||||
/gateSummary\.auditEvents\.slice\(0,\s*4\)\.map/u.test(app) &&
|
||||
/gateSummary\.evidenceRecords\.map/u.test(app) &&
|
||||
/safeFailureReason\(message\.error\.message\)/u.test(app) &&
|
||||
/message\.status === "failed" \? "blocked" : "source"/u.test(app) &&
|
||||
failedRecordsStayBlocked &&
|
||||
!/message\.status === "completed"\s*\?\s*"dev-live"/u.test(app)
|
||||
);
|
||||
}
|
||||
@@ -2326,7 +2329,7 @@ function hasStableRouteControls({ html, styles }) {
|
||||
/\.(?:status-dot|state-tag|badge)[^{]*\{[^}]*max-width:\s*100%;[^}]*line-height:\s*1\.25;[^}]*white-space:\s*normal;[^}]*overflow-wrap:\s*anywhere;/su.test(styles) &&
|
||||
/\.probe-card\s*\{[^}]*min-width:\s*0;/su.test(styles) &&
|
||||
/\.probe-card strong\s*\{[^}]*line-height:\s*1\.2;[^}]*overflow-wrap:\s*anywhere;/su.test(styles) &&
|
||||
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.message-trace,\s*\n\s*\.message-m3-evidence\s*\{[\s\S]*?grid-column:\s*1;/u.test(styles)
|
||||
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.message-trace,\s*\n\s*\.message-m3-evidence(?:,\s*\n\s*\.message-actions)?\s*\{[\s\S]*?grid-column:\s*1;/u.test(styles)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2569,7 +2572,10 @@ function hasCodeAgentStatusSummaryContract({ html, app, styles, codeAgentStatus
|
||||
}
|
||||
|
||||
function hasCodeAgentConversationUxStates({ app, styles }) {
|
||||
const submitBody = functionBody(app, "initCommandBar");
|
||||
const submitBody = `${functionBody(app, "initCommandBar")}\n${functionBody(app, "submitAgentMessage")}`;
|
||||
const statusToneContract =
|
||||
/status === "completed" \? "dev-live" : status === "failed" \|\| status === "blocked" \? "blocked" : status === "running" \? "pending" : "source"/u.test(app) ||
|
||||
/status === "completed" \? "dev-live" : \["failed",\s*"blocked",\s*"timeout",\s*"canceled",\s*"error"\]\.includes\(status\) \? "blocked" : status === "running" \? "pending" : "source"/u.test(app);
|
||||
return (
|
||||
/function\s+classifyCodeAgentCompletion\s*\(/u.test(app) &&
|
||||
/function\s+isSourceFixtureChatResult\s*\(/u.test(app) &&
|
||||
@@ -2607,7 +2613,7 @@ function hasCodeAgentConversationUxStates({ app, styles }) {
|
||||
!/message-attribution\s*\{/u.test(styles) &&
|
||||
!/message-evidence\s*\{/u.test(styles) &&
|
||||
/width:\s*min\(100%,\s*780px\)/u.test(styles) &&
|
||||
/status === "completed" \? "dev-live" : status === "failed" \|\| status === "blocked" \? "blocked" : status === "running" \? "pending" : "source"/u.test(app) &&
|
||||
statusToneContract &&
|
||||
!/sourceKind:\s*"SOURCE"[\s\S]{0,160}status:\s*"completed"/u.test(app)
|
||||
);
|
||||
}
|
||||
@@ -2667,7 +2673,7 @@ function m3RenderedWorkbenchNotM5Fixture(app) {
|
||||
}
|
||||
|
||||
function functionBody(source, functionName) {
|
||||
const match = source.match(new RegExp(`function\\s+${escapeRegExp(functionName)}\\s*\\([^)]*\\)\\s*\\{`, "u"));
|
||||
const match = source.match(new RegExp(`(?:async\\s+)?function\\s+${escapeRegExp(functionName)}\\s*\\([^)]*\\)\\s*\\{`, "u"));
|
||||
if (!match) return "";
|
||||
let depth = 0;
|
||||
for (let index = match.index + match[0].length - 1; index < source.length; index += 1) {
|
||||
|
||||
@@ -334,6 +334,7 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "",
|
||||
"/v1/diagnostics/gate",
|
||||
"/v1/live-builds",
|
||||
"/v1/agent/chat",
|
||||
"/v1/agent/chat/cancel",
|
||||
"/v1/m3/io",
|
||||
"/v1/m3/status",
|
||||
"/json-rpc"
|
||||
|
||||
+413
-148
@@ -32,6 +32,7 @@ const STATUS_LABELS = Object.freeze({
|
||||
available: "可用",
|
||||
blocked_after_cloud_api: "cloud-api 后阻塞",
|
||||
blocked: "待处理",
|
||||
canceled: "已取消",
|
||||
completed: "完成",
|
||||
connected: "已连接",
|
||||
degraded: "降级",
|
||||
@@ -45,6 +46,8 @@ const STATUS_LABELS = Object.freeze({
|
||||
pass: "通过",
|
||||
pending: "等待",
|
||||
running: "处理中",
|
||||
timeout: "超时",
|
||||
error: "错误",
|
||||
probing: "探测中",
|
||||
ready: "就绪",
|
||||
recorded: "已记录",
|
||||
@@ -149,6 +152,9 @@ const state = {
|
||||
codeAgentAvailability: null,
|
||||
chatMessages: [],
|
||||
traceStreams: new Map(),
|
||||
canceledTraces: new Set(),
|
||||
currentRequest: null,
|
||||
sessionStatus: null,
|
||||
chatPending: false,
|
||||
liveSurface: null,
|
||||
gateDiagnostics: {
|
||||
@@ -605,151 +611,18 @@ function initCommandBar() {
|
||||
el.commandForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const value = el.commandInput.value.trim();
|
||||
if (!value || state.chatPending) return;
|
||||
const traceId = nextProtocolId("trc");
|
||||
const activeConversationId = state.conversationId ?? nextProtocolId("cnv");
|
||||
state.conversationId = activeConversationId;
|
||||
const userMessage = {
|
||||
id: nextProtocolId("msg"),
|
||||
role: "user",
|
||||
title: `用户 ${shortTime(new Date().toISOString())}`,
|
||||
text: value,
|
||||
status: "sent",
|
||||
traceId,
|
||||
conversationId: activeConversationId,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
const pendingMessage = {
|
||||
id: nextProtocolId("msg"),
|
||||
role: "agent",
|
||||
title: "Code Agent 处理中",
|
||||
text: `正在处理这次 Code Agent 请求;复杂问题可能需要 1-2 分钟。页面会保留 trace/session 并等待后端返回成功、结构化 blocker 或真实超时;不会把旧 4500ms 轻量探测窗口当作最终失败。`,
|
||||
status: "running",
|
||||
traceId,
|
||||
conversationId: activeConversationId,
|
||||
sessionId: state.sessionId,
|
||||
sourceKind: "PENDING",
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
state.chatMessages.push(userMessage, pendingMessage);
|
||||
state.chatPending = true;
|
||||
el.commandInput.value = "";
|
||||
renderAgentChatStatus("running");
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
renderDrafts();
|
||||
renderRecords(state.liveSurface);
|
||||
|
||||
const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id);
|
||||
|
||||
try {
|
||||
const result = await sendAgentMessage(value, activeConversationId, traceId);
|
||||
stopTraceStream();
|
||||
state.conversationId = result.conversationId || result.sessionId || state.conversationId;
|
||||
state.sessionId = result.sessionId || state.sessionId || state.conversationId;
|
||||
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
|
||||
const completion = classifyCodeAgentCompletion(result);
|
||||
const status = completion.status;
|
||||
const structuredBlockedError = structuredBlockedErrorFromResult(result);
|
||||
state.chatMessages[index] = {
|
||||
...pendingMessage,
|
||||
title: completion.title,
|
||||
text: completion.replied
|
||||
? result.reply?.content || "Code Agent 没有返回文本。"
|
||||
: structuredBlockedError
|
||||
? failureMessage({ ...result, error: structuredBlockedError })
|
||||
: result.status === "completed"
|
||||
? untrustedCompletionMessage(result)
|
||||
: failureMessage(result),
|
||||
status,
|
||||
traceId: result.traceId,
|
||||
conversationId: result.conversationId || result.sessionId || state.conversationId,
|
||||
sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId,
|
||||
messageId: result.messageId,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
backend: result.backend,
|
||||
workspace: result.workspace,
|
||||
sandbox: result.sandbox,
|
||||
session: result.session,
|
||||
sessionMode: result.sessionMode,
|
||||
sessionReuse: result.sessionReuse,
|
||||
implementationType: result.implementationType,
|
||||
runnerLimitations: result.runnerLimitations,
|
||||
codexStdioFeasibility: result.codexStdioFeasibility,
|
||||
longLivedSessionGate: result.longLivedSessionGate,
|
||||
toolCalls: result.toolCalls,
|
||||
skills: result.skills,
|
||||
runner: result.runner,
|
||||
runnerTrace: result.runnerTrace,
|
||||
conversationFacts: result.conversationFacts,
|
||||
m3Evidence: extractCodeAgentM3Evidence(result),
|
||||
capabilityLevel: result.capabilityLevel,
|
||||
sourceKind: completion.sourceKind,
|
||||
providerTrace: result.providerTrace,
|
||||
blocker: result.blocker,
|
||||
blockers: result.blockers,
|
||||
updatedAt: result.updatedAt,
|
||||
error: structuredBlockedError ?? result.error ?? (result.status === "completed" && !completion.replied
|
||||
? {
|
||||
code: "untrusted_completion",
|
||||
message: "completed 回复缺少真实 provider/model/trace/conversation 证据,或 provider 属于 echo/mock/stub。"
|
||||
}
|
||||
: undefined),
|
||||
availability: result.availability
|
||||
};
|
||||
if (result.availability) {
|
||||
state.codeAgentAvailability = result.availability;
|
||||
}
|
||||
if (!completion.replied) {
|
||||
el.commandInput.value = value;
|
||||
}
|
||||
renderAgentChatStatus(status, result);
|
||||
renderCodeAgentSummary();
|
||||
} catch (error) {
|
||||
stopTraceStream();
|
||||
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
|
||||
const presentation = agentFailurePresentation(error, { traceId });
|
||||
state.chatMessages[index] = {
|
||||
...pendingMessage,
|
||||
title: presentation.title,
|
||||
text: presentation.text,
|
||||
status: "failed",
|
||||
traceId: error.traceId || traceId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
error: {
|
||||
code: error.code || "request_failed",
|
||||
category: presentation.category,
|
||||
layer: error.layer,
|
||||
blocker: error.blocker,
|
||||
retryable: error.retryable,
|
||||
userMessage: error.userMessage,
|
||||
message: error.message,
|
||||
timeoutMs: error.timeoutMs,
|
||||
providerStatus: error.providerStatus,
|
||||
missingConfig: error.missingConfig,
|
||||
route: error.route,
|
||||
toolName: error.toolName
|
||||
},
|
||||
runnerTrace: latestTraceSnapshot(traceId)
|
||||
};
|
||||
el.commandInput.value = value;
|
||||
renderAgentChatStatus("failed", state.chatMessages[index]);
|
||||
renderCodeAgentSummary();
|
||||
} finally {
|
||||
state.chatPending = false;
|
||||
stopTraceStream();
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
renderDrafts();
|
||||
renderRecords(state.liveSurface);
|
||||
}
|
||||
await submitAgentMessage(value);
|
||||
});
|
||||
|
||||
el.commandClear.addEventListener("click", () => {
|
||||
for (const close of state.traceStreams.values()) close();
|
||||
state.traceStreams.clear();
|
||||
state.chatMessages = [];
|
||||
state.conversationId = null;
|
||||
state.sessionId = null;
|
||||
state.sessionStatus = null;
|
||||
state.currentRequest = null;
|
||||
state.canceledTraces.clear();
|
||||
state.chatPending = false;
|
||||
el.commandInput.value = "";
|
||||
renderAgentChatStatus("idle");
|
||||
@@ -760,6 +633,183 @@ function initCommandBar() {
|
||||
});
|
||||
}
|
||||
|
||||
async function submitAgentMessage(value, options = {}) {
|
||||
if (!value || state.chatPending) return;
|
||||
const traceId = options.traceId ?? nextProtocolId("trc");
|
||||
const activeConversationId = options.conversationId ?? state.conversationId ?? nextProtocolId("cnv");
|
||||
const requestedSessionId = options.sessionId === undefined ? sessionIdForNextRequest() : options.sessionId;
|
||||
state.conversationId = activeConversationId;
|
||||
const userMessage = {
|
||||
id: nextProtocolId("msg"),
|
||||
role: "user",
|
||||
title: `${options.retryOf ? "重试" : "用户"} ${shortTime(new Date().toISOString())}`,
|
||||
text: value,
|
||||
status: "sent",
|
||||
traceId,
|
||||
conversationId: activeConversationId,
|
||||
sessionId: requestedSessionId,
|
||||
retryOf: options.retryOf ?? null,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
const pendingMessage = {
|
||||
id: nextProtocolId("msg"),
|
||||
role: "agent",
|
||||
title: options.retryOf ? "Code Agent 重试中" : "Code Agent 处理中",
|
||||
text: `正在处理这次 Code Agent 请求;复杂问题可能需要 1-2 分钟。页面会保留 trace/session 并等待后端返回成功、结构化 blocker 或真实超时;不会把旧 4500ms 轻量探测窗口当作最终失败。`,
|
||||
status: "running",
|
||||
traceId,
|
||||
conversationId: activeConversationId,
|
||||
sessionId: requestedSessionId,
|
||||
retryInput: value,
|
||||
retryOf: options.retryOf ?? null,
|
||||
sourceKind: "PENDING",
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
state.chatMessages.push(userMessage, pendingMessage);
|
||||
state.chatPending = true;
|
||||
state.currentRequest = {
|
||||
traceId,
|
||||
conversationId: activeConversationId,
|
||||
sessionId: requestedSessionId,
|
||||
messageId: pendingMessage.id,
|
||||
input: value
|
||||
};
|
||||
el.commandInput.value = "";
|
||||
renderAgentChatStatus("running");
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
renderDrafts();
|
||||
renderRecords(state.liveSurface);
|
||||
|
||||
const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id);
|
||||
|
||||
try {
|
||||
const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId);
|
||||
stopTraceStream();
|
||||
if (state.canceledTraces.has(traceId)) return;
|
||||
state.conversationId = result.conversationId || result.sessionId || state.conversationId;
|
||||
state.sessionId = result.sessionId || result.session?.sessionId || state.sessionId || state.conversationId;
|
||||
state.sessionStatus = result.session?.status ?? result.runnerTrace?.sessionStatus ?? result.status ?? state.sessionStatus;
|
||||
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
|
||||
const completion = classifyCodeAgentCompletion(result);
|
||||
const status = completion.status;
|
||||
const structuredBlockedError = structuredBlockedErrorFromResult(result);
|
||||
state.chatMessages[index] = {
|
||||
...pendingMessage,
|
||||
title: completion.title,
|
||||
text: completion.replied
|
||||
? result.reply?.content || "Code Agent 没有返回文本。"
|
||||
: structuredBlockedError
|
||||
? failureMessage({ ...result, error: structuredBlockedError })
|
||||
: result.status === "completed"
|
||||
? untrustedCompletionMessage(result)
|
||||
: failureMessage(result),
|
||||
status,
|
||||
traceId: result.traceId,
|
||||
conversationId: result.conversationId || result.sessionId || state.conversationId,
|
||||
sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId,
|
||||
messageId: result.messageId,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
backend: result.backend,
|
||||
workspace: result.workspace,
|
||||
sandbox: result.sandbox,
|
||||
session: result.session,
|
||||
sessionMode: result.sessionMode,
|
||||
sessionReuse: result.sessionReuse,
|
||||
implementationType: result.implementationType,
|
||||
runnerLimitations: result.runnerLimitations,
|
||||
codexStdioFeasibility: result.codexStdioFeasibility,
|
||||
longLivedSessionGate: result.longLivedSessionGate,
|
||||
toolCalls: result.toolCalls,
|
||||
skills: result.skills,
|
||||
runner: result.runner,
|
||||
runnerTrace: result.runnerTrace,
|
||||
conversationFacts: result.conversationFacts,
|
||||
m3Evidence: extractCodeAgentM3Evidence(result),
|
||||
capabilityLevel: result.capabilityLevel,
|
||||
sourceKind: completion.sourceKind,
|
||||
providerTrace: result.providerTrace,
|
||||
blocker: result.blocker,
|
||||
blockers: result.blockers,
|
||||
updatedAt: result.updatedAt,
|
||||
retryInput: value,
|
||||
retryOf: options.retryOf ?? null,
|
||||
error: structuredBlockedError ?? result.error ?? (result.status === "completed" && !completion.replied
|
||||
? {
|
||||
code: "untrusted_completion",
|
||||
message: "completed 回复缺少真实 provider/model/trace/conversation 证据,或 provider 属于 echo/mock/stub。"
|
||||
}
|
||||
: undefined),
|
||||
availability: result.availability
|
||||
};
|
||||
if (result.availability) {
|
||||
state.codeAgentAvailability = result.availability;
|
||||
}
|
||||
if (!completion.replied) {
|
||||
el.commandInput.value = value;
|
||||
}
|
||||
renderAgentChatStatus(status, result);
|
||||
renderCodeAgentSummary();
|
||||
} catch (error) {
|
||||
stopTraceStream();
|
||||
if (state.canceledTraces.has(traceId)) return;
|
||||
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
|
||||
const presentation = agentFailurePresentation(error, { traceId });
|
||||
const failedStatus = errorStatusFromPresentation(presentation, error);
|
||||
state.chatMessages[index] = {
|
||||
...pendingMessage,
|
||||
title: presentation.title,
|
||||
text: presentation.text,
|
||||
status: failedStatus,
|
||||
traceId: error.traceId || traceId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
retryInput: value,
|
||||
error: {
|
||||
code: error.code || "request_failed",
|
||||
category: presentation.category,
|
||||
layer: error.layer,
|
||||
blocker: error.blocker,
|
||||
retryable: error.retryable,
|
||||
userMessage: error.userMessage,
|
||||
message: error.message,
|
||||
timeoutMs: error.timeoutMs,
|
||||
providerStatus: error.providerStatus,
|
||||
missingConfig: error.missingConfig,
|
||||
route: error.route,
|
||||
toolName: error.toolName
|
||||
},
|
||||
runnerTrace: latestTraceSnapshot(traceId)
|
||||
};
|
||||
state.sessionStatus = state.chatMessages[index].runnerTrace?.sessionStatus ?? failedStatus;
|
||||
el.commandInput.value = value;
|
||||
renderAgentChatStatus(failedStatus, state.chatMessages[index]);
|
||||
renderCodeAgentSummary();
|
||||
} finally {
|
||||
if (state.currentRequest?.traceId === traceId) state.currentRequest = null;
|
||||
state.chatPending = false;
|
||||
stopTraceStream();
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
renderDrafts();
|
||||
renderRecords(state.liveSurface);
|
||||
}
|
||||
}
|
||||
|
||||
function sessionIdForNextRequest() {
|
||||
return isTerminalSessionStatus(state.sessionStatus) ? undefined : state.sessionId;
|
||||
}
|
||||
|
||||
function isTerminalSessionStatus(status) {
|
||||
return ["failed", "interrupted", "timeout", "error", "canceled", "expired"].includes(String(status ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
function errorStatusFromPresentation(presentation, error) {
|
||||
if (presentation.category === "timeout") return "timeout";
|
||||
if (presentation.category === "canceled" || error?.code === "codex_stdio_canceled") return "canceled";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function subscribeRunnerTrace(traceId, messageId) {
|
||||
if (typeof EventSource !== "function") return pollRunnerTrace(traceId, messageId);
|
||||
const existing = state.traceStreams.get(traceId);
|
||||
@@ -823,12 +873,19 @@ function updateMessageTrace(messageId, snapshot, options = {}) {
|
||||
if (index < 0) return;
|
||||
const current = state.chatMessages[index];
|
||||
const runnerTrace = runnerTraceFromSnapshot(snapshot, current.runnerTrace);
|
||||
const sessionId = snapshot.sessionId ?? runnerTrace.sessionId ?? current.sessionId;
|
||||
state.chatMessages[index] = {
|
||||
...current,
|
||||
runnerTrace,
|
||||
traceId: snapshot.traceId ?? current.traceId,
|
||||
sessionId,
|
||||
updatedAt: snapshot.updatedAt ?? current.updatedAt
|
||||
};
|
||||
if (sessionId) state.sessionId = sessionId;
|
||||
if (runnerTrace.sessionStatus) state.sessionStatus = runnerTrace.sessionStatus;
|
||||
if (state.currentRequest?.traceId === (snapshot.traceId ?? current.traceId) && sessionId) {
|
||||
state.currentRequest.sessionId = sessionId;
|
||||
}
|
||||
if (options.quiet !== true) {
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
@@ -907,8 +964,8 @@ function renderProbePending() {
|
||||
renderCodeAgentSummary();
|
||||
}
|
||||
|
||||
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc")) {
|
||||
const sessionId = state.conversationId === conversationId ? state.sessionId : undefined;
|
||||
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest()) {
|
||||
const sessionId = requestedSessionId || undefined;
|
||||
const response = await fetchJson("/v1/agent/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -2089,14 +2146,14 @@ function codeAgentRecordCards() {
|
||||
infoCard({
|
||||
title: `${roleLabel(message.role)} ${statusLabel(message.status)}`,
|
||||
detail: [
|
||||
sourceKindLabel(message.status === "running" ? "pending" : message.status === "failed" ? "blocked" : "source"),
|
||||
sourceKindLabel(message.status === "running" ? "pending" : ["failed", "timeout", "canceled", "error"].includes(message.status) ? "blocked" : "source"),
|
||||
recordField("conversation", message.conversationId ?? state.conversationId),
|
||||
recordField("trace", message.traceId),
|
||||
recordField("session", message.sessionId ?? sessionSummary(message.session)),
|
||||
recordField("message", message.messageId),
|
||||
message.error?.message ? `失败原因=${safeFailureReason(message.error.message)}` : null
|
||||
].filter(Boolean).join(" / "),
|
||||
tone: message.status === "running" ? "pending" : message.status === "failed" ? "blocked" : "source"
|
||||
tone: message.status === "running" ? "pending" : ["failed", "timeout", "canceled", "error"].includes(message.status) ? "blocked" : "source"
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -2427,10 +2484,13 @@ function renderAgentChatStatus(status, result = null) {
|
||||
completed: "DEV-LIVE 回复",
|
||||
source: "SOURCE 回复",
|
||||
failed: "发送失败",
|
||||
timeout: "等待超时",
|
||||
canceled: "已取消",
|
||||
error: "请求错误",
|
||||
blocked: "服务受阻"
|
||||
};
|
||||
el.agentChatStatus.textContent = agentStatusLabel(status, result, labels);
|
||||
el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : status === "failed" || status === "blocked" ? "blocked" : status === "running" ? "pending" : "source")}`;
|
||||
el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : ["failed", "blocked", "timeout", "canceled", "error"].includes(status) ? "blocked" : status === "running" ? "pending" : "source")}`;
|
||||
el.commandInput.disabled = status === "running";
|
||||
el.commandSend.disabled = status === "running";
|
||||
el.commandSend.textContent = status === "running" ? "发送中" : "发送";
|
||||
@@ -2506,6 +2566,7 @@ function agentStatusLabel(status, result, labels) {
|
||||
provider: "Provider 不可用",
|
||||
runner_busy: "Runner 忙碌",
|
||||
session_blocked: "Session 受阻",
|
||||
canceled: "已取消",
|
||||
runner_blocked: "Runner 受阻",
|
||||
api_error: "API 错误",
|
||||
needs_config: "需要配置",
|
||||
@@ -2530,6 +2591,30 @@ function textFallbackTitle(result) {
|
||||
}
|
||||
|
||||
function classifyCodeAgentCompletion(result) {
|
||||
if (result?.status === "canceled") {
|
||||
return {
|
||||
status: "canceled",
|
||||
replied: false,
|
||||
sourceKind: "BLOCKED",
|
||||
title: "Code Agent 已取消"
|
||||
};
|
||||
}
|
||||
if (result?.status === "timeout") {
|
||||
return {
|
||||
status: "timeout",
|
||||
replied: false,
|
||||
sourceKind: "BLOCKED",
|
||||
title: "Code Agent 等待超时"
|
||||
};
|
||||
}
|
||||
if (result?.status === "error") {
|
||||
return {
|
||||
status: "failed",
|
||||
replied: false,
|
||||
sourceKind: "BLOCKED",
|
||||
title: "Code Agent 返回错误"
|
||||
};
|
||||
}
|
||||
if (isStructuredBlockedChatResult(result)) {
|
||||
return {
|
||||
status: "failed",
|
||||
@@ -2668,6 +2753,14 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {})
|
||||
};
|
||||
}
|
||||
|
||||
if (code === "codex_stdio_canceled" || code === "session_canceled") {
|
||||
return {
|
||||
category: "canceled",
|
||||
title: "Code Agent 已取消",
|
||||
text: `本次 Codex stdio 请求已取消;输入、sessionId 和 traceId 已保留,可重试上一条消息。${traceSuffix}`
|
||||
};
|
||||
}
|
||||
|
||||
if (userMessage) {
|
||||
const category = error?.category ?? result?.error?.category ?? structuredBlocker?.category ?? "runner_blocked";
|
||||
return {
|
||||
@@ -2685,7 +2778,7 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {})
|
||||
};
|
||||
}
|
||||
|
||||
if (["session_expired", "session_reuse_conflict", "session_failed", "session_interrupted"].includes(code)) {
|
||||
if (["session_expired", "session_reuse_conflict", "session_failed", "session_interrupted", "session_canceled"].includes(code)) {
|
||||
return {
|
||||
category: "session_blocked",
|
||||
title: "Code Agent Session 受阻",
|
||||
@@ -2732,6 +2825,7 @@ function titleForBlockerCategory(category, code) {
|
||||
if (value === "security_blocked" || code === "security_blocked") return "Code Agent 安全阻断";
|
||||
if (value === "fallback" || code === "text_chat_only_fallback") return "Code Agent 仍是 fallback";
|
||||
if (value === "timeout") return "Code Agent 等待超时";
|
||||
if (value === "canceled" || code === "codex_stdio_canceled") return "Code Agent 已取消";
|
||||
if (value === "runner_busy") return "Code Agent Runner 忙碌";
|
||||
if (value === "session_blocked") return "Code Agent Session 受阻";
|
||||
if (value === "capability_unavailable") return "Code Agent 能力未开放";
|
||||
@@ -2768,16 +2862,184 @@ function messageCard(message) {
|
||||
if (m3Evidence) article.append(m3Evidence);
|
||||
const tracePanel = messageTracePanel(message);
|
||||
if (tracePanel) article.append(tracePanel);
|
||||
const actions = messageActionsPanel(message);
|
||||
if (actions) article.append(actions);
|
||||
return article;
|
||||
}
|
||||
|
||||
function messageActionsPanel(message) {
|
||||
if (message.role !== "agent") return null;
|
||||
const actions = [];
|
||||
if (message.status === "running") {
|
||||
actions.push(actionButton("取消当前请求", "cancel", () => cancelAgentMessage(message.id), "取消当前 in-flight Codex stdio 请求"));
|
||||
}
|
||||
if (canRetryAgentMessage(message)) {
|
||||
actions.push(actionButton("重试上一条", "retry", () => retryAgentMessage(message.id), "保留 conversation/trace 记录并重新发送上一条输入"));
|
||||
}
|
||||
if (message.traceId) {
|
||||
actions.push(actionButton("回放 trace", "trace", () => replayAgentTrace(message.id), "从 runnerTrace store 重新读取真实事件"));
|
||||
}
|
||||
if (actions.length === 0 && !message.traceReplayStatus) return null;
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "message-actions";
|
||||
panel.append(...actions);
|
||||
if (message.traceReplayStatus) {
|
||||
panel.append(textSpan(message.traceReplayStatus, "message-action-status"));
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
function actionButton(label, action, onClick, title) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `message-action message-action-${action}`;
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
button.addEventListener("click", onClick);
|
||||
return button;
|
||||
}
|
||||
|
||||
function canRetryAgentMessage(message) {
|
||||
return Boolean(
|
||||
message?.retryInput &&
|
||||
["failed", "timeout", "error", "canceled"].includes(String(message.status ?? "").toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
async function retryAgentMessage(messageId) {
|
||||
const message = state.chatMessages.find((item) => item.id === messageId);
|
||||
if (!message?.retryInput || state.chatPending) return;
|
||||
await submitAgentMessage(message.retryInput, {
|
||||
conversationId: message.conversationId || state.conversationId || undefined,
|
||||
sessionId: isTerminalSessionStatus(message.session?.status ?? message.runnerTrace?.sessionStatus ?? message.status)
|
||||
? undefined
|
||||
: message.sessionId,
|
||||
retryOf: message.traceId
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelAgentMessage(messageId) {
|
||||
const index = state.chatMessages.findIndex((message) => message.id === messageId);
|
||||
if (index < 0) return;
|
||||
const message = state.chatMessages[index];
|
||||
const traceId = message.traceId;
|
||||
const sessionId = message.sessionId || message.runnerTrace?.sessionId || state.currentRequest?.sessionId || state.sessionId;
|
||||
const response = await cancelAgentRequest({
|
||||
traceId,
|
||||
conversationId: message.conversationId || state.conversationId,
|
||||
sessionId
|
||||
});
|
||||
const payload = response.data ?? {};
|
||||
const runnerTrace = payload.runnerTrace ?? latestTraceSnapshot(traceId);
|
||||
if (response.ok && payload.canceled === true) {
|
||||
state.canceledTraces.add(traceId);
|
||||
state.chatPending = false;
|
||||
state.currentRequest = null;
|
||||
state.sessionId = payload.sessionId || sessionId || state.sessionId;
|
||||
state.sessionStatus = payload.session?.status ?? runnerTrace?.sessionStatus ?? "canceled";
|
||||
state.chatMessages[index] = {
|
||||
...message,
|
||||
title: "Code Agent 已取消",
|
||||
text: payload.userMessage || "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。",
|
||||
status: "canceled",
|
||||
sessionId: payload.sessionId || sessionId || message.sessionId,
|
||||
session: payload.session ?? message.session,
|
||||
runnerTrace,
|
||||
traceReplayStatus: lastTraceEventLabel(runnerTrace),
|
||||
updatedAt: new Date().toISOString(),
|
||||
error: payload.error ?? {
|
||||
code: "codex_stdio_canceled",
|
||||
category: "canceled",
|
||||
retryable: true,
|
||||
message: "user canceled current Code Agent request"
|
||||
}
|
||||
};
|
||||
if (message.retryInput) el.commandInput.value = message.retryInput;
|
||||
} else {
|
||||
state.chatMessages[index] = {
|
||||
...message,
|
||||
title: "Code Agent 取消受阻",
|
||||
text: payload.error?.userMessage || response.error || "当前请求没有可取消的 in-flight Codex stdio session;输入和 trace 已保留。",
|
||||
status: "failed",
|
||||
runnerTrace,
|
||||
traceReplayStatus: lastTraceEventLabel(runnerTrace),
|
||||
updatedAt: new Date().toISOString(),
|
||||
error: payload.error ?? {
|
||||
code: "cancel_blocked",
|
||||
category: "cancel_blocked",
|
||||
retryable: true,
|
||||
message: response.error || "cancel blocked"
|
||||
}
|
||||
};
|
||||
if (message.retryInput) el.commandInput.value = message.retryInput;
|
||||
}
|
||||
renderAgentChatStatus(state.chatMessages[index].status, state.chatMessages[index]);
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
renderDrafts();
|
||||
renderRecords(state.liveSurface);
|
||||
}
|
||||
|
||||
async function cancelAgentRequest({ traceId, conversationId, sessionId }) {
|
||||
return fetchJson("/v1/agent/chat/cancel", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Trace-Id": traceId
|
||||
},
|
||||
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
|
||||
timeoutName: "Code Agent cancel",
|
||||
body: JSON.stringify({
|
||||
traceId,
|
||||
conversationId,
|
||||
sessionId
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async function replayAgentTrace(messageId) {
|
||||
const index = state.chatMessages.findIndex((message) => message.id === messageId);
|
||||
if (index < 0) return;
|
||||
const message = state.chatMessages[index];
|
||||
if (!message.traceId) return;
|
||||
const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, {
|
||||
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
|
||||
timeoutName: "Code Agent trace replay"
|
||||
});
|
||||
if (response.ok) {
|
||||
const runnerTrace = runnerTraceFromSnapshot(response.data, message.runnerTrace);
|
||||
state.chatMessages[index] = {
|
||||
...message,
|
||||
runnerTrace,
|
||||
traceReplayStatus: `已回放 ${runnerTrace.events?.length ?? 0} 个真实 trace event;${lastTraceEventLabel(runnerTrace)}`,
|
||||
updatedAt: response.data?.updatedAt ?? new Date().toISOString()
|
||||
};
|
||||
} else {
|
||||
state.chatMessages[index] = {
|
||||
...message,
|
||||
traceReplayStatus: response.error || "trace 回放失败",
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
renderRecords(state.liveSurface);
|
||||
}
|
||||
|
||||
function lastTraceEventLabel(runnerTrace) {
|
||||
const event = runnerTrace?.lastEvent ?? (Array.isArray(runnerTrace?.events) ? runnerTrace.events.at(-1) : null);
|
||||
if (!event) return "lastEvent=none";
|
||||
return `lastEvent=${event.label ?? `${event.type ?? "event"}:${event.status ?? "observed"}`}`;
|
||||
}
|
||||
|
||||
function messagePendingContextPanel(message) {
|
||||
if (message.status !== "running") return null;
|
||||
const fields = [
|
||||
["状态", "后端处理中"],
|
||||
["traceId", message.traceId],
|
||||
["conversation", message.conversationId],
|
||||
["session", message.sessionId ?? "等待后端分配"],
|
||||
["session", message.sessionId ?? message.runnerTrace?.sessionId ?? "等待后端分配"],
|
||||
["sessionStatus", message.runnerTrace?.sessionStatus],
|
||||
["timeout", `${CODE_AGENT_TIMEOUT_MS}ms`]
|
||||
];
|
||||
const section = document.createElement("section");
|
||||
@@ -2842,7 +3104,7 @@ function messageTracePanel(message) {
|
||||
if (!trace && !message.traceId) return null;
|
||||
const details = document.createElement("details");
|
||||
details.className = "message-trace";
|
||||
details.open = message.status === "running" || message.status === "completed" || message.status === "source" || message.status === "failed";
|
||||
details.open = ["running", "completed", "source", "failed", "timeout", "canceled", "error"].includes(message.status);
|
||||
const summary = document.createElement("summary");
|
||||
summary.className = "message-meta";
|
||||
summary.textContent = runnerTraceHeadline(message, trace);
|
||||
@@ -3390,6 +3652,9 @@ function deriveAgentChatStatus() {
|
||||
const latest = latestChatResult();
|
||||
if (latest?.status === "completed") return "completed";
|
||||
if (latest?.status === "source") return "source";
|
||||
if (latest?.status === "canceled") return "canceled";
|
||||
if (latest?.status === "timeout") return "timeout";
|
||||
if (latest?.status === "error") return "error";
|
||||
if (latest?.status === "failed") return latest.error?.code === "provider_unavailable" ? "blocked" : "failed";
|
||||
if (state.codeAgentAvailability?.status === "blocked") return "blocked";
|
||||
return "idle";
|
||||
@@ -3398,7 +3663,7 @@ function deriveAgentChatStatus() {
|
||||
function currentConversationTone() {
|
||||
if (latestCompletedAgentMessage()) return "dev-live";
|
||||
if (isSourceFixtureCompletedChatMessage(latestChatResult())) return "source";
|
||||
if (state.codeAgentAvailability?.status === "blocked" || latestChatResult()?.status === "failed") return "blocked";
|
||||
if (state.codeAgentAvailability?.status === "blocked" || ["failed", "timeout", "canceled", "error"].includes(latestChatResult()?.status)) return "blocked";
|
||||
return "source";
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,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 会继续更新。");
|
||||
}
|
||||
|
||||
if (payload.status === "canceled" || payload.session?.status === "canceled" || payload.error?.code === "codex_stdio_canceled") {
|
||||
return status("canceled", "请求已取消", "blocked", "×", "当前请求已取消,输入、sessionId 和 traceId 已保留,可重试。");
|
||||
}
|
||||
|
||||
if (payload.status === "timeout" || payload.session?.status === "timeout") {
|
||||
return status("timeout", "等待超时", "blocked", "×", "当前请求超时,输入、sessionId 和 traceId 已保留,可重试。");
|
||||
}
|
||||
|
||||
if (payload.status === "error" || payload.session?.status === "error") {
|
||||
return status("error", "请求错误", "blocked", "×", "当前请求返回错误,输入、sessionId 和 traceId 已保留,可重试。");
|
||||
}
|
||||
|
||||
if (isSkillCliApiControl(payload)) {
|
||||
const ready = isReadySkillCli(payload);
|
||||
return ready
|
||||
|
||||
@@ -218,8 +218,8 @@ test("summarizes active Code Agent running request as pending with trace and ses
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(summary.kind, "unverified");
|
||||
assert.equal(summary.label, "探测中");
|
||||
assert.equal(summary.kind, "busy");
|
||||
assert.equal(summary.label, "请求处理中");
|
||||
assert.equal(summary.tone, "pending");
|
||||
assert.equal(summary.codeAgentStatus, "running");
|
||||
assert.equal(summary.sessionId, "cnv_pending_heavy_skills");
|
||||
@@ -227,6 +227,80 @@ test("summarizes active Code Agent running request as pending with trace and ses
|
||||
assert.equal(summary.readinessBlockers.length, 0);
|
||||
});
|
||||
|
||||
test("summarizes canceled Code Agent request with preserved retry context", () => {
|
||||
const summary = classifyCodeAgentStatusSummary({
|
||||
latestMessage: {
|
||||
role: "agent",
|
||||
status: "canceled",
|
||||
traceId: "trc_cancel_keep_context",
|
||||
conversationId: "cnv_cancel_keep_context",
|
||||
sessionId: "ses_cancel_keep_context",
|
||||
session: {
|
||||
sessionId: "ses_cancel_keep_context",
|
||||
status: "canceled"
|
||||
},
|
||||
runnerTrace: {
|
||||
traceId: "trc_cancel_keep_context",
|
||||
sessionStatus: "canceled",
|
||||
lastEvent: {
|
||||
type: "cancel",
|
||||
label: "cancel:canceled",
|
||||
status: "canceled"
|
||||
}
|
||||
},
|
||||
error: {
|
||||
code: "codex_stdio_canceled",
|
||||
message: "请求已取消"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(summary.kind, "canceled");
|
||||
assert.equal(summary.label, "请求已取消");
|
||||
assert.equal(summary.tone, "blocked");
|
||||
assert.equal(summary.codeAgentStatus, "canceled");
|
||||
assert.equal(summary.sessionId, "ses_cancel_keep_context");
|
||||
assert.equal(summary.sessionStatus, "canceled");
|
||||
assert.equal(summary.lastTraceId, "trc_cancel_keep_context");
|
||||
});
|
||||
|
||||
test("summarizes timed out Code Agent request with preserved trace context", () => {
|
||||
const summary = classifyCodeAgentStatusSummary({
|
||||
latestMessage: {
|
||||
role: "agent",
|
||||
status: "timeout",
|
||||
traceId: "trc_timeout_keep_context",
|
||||
conversationId: "cnv_timeout_keep_context",
|
||||
sessionId: "ses_timeout_keep_context",
|
||||
session: {
|
||||
sessionId: "ses_timeout_keep_context",
|
||||
status: "timeout"
|
||||
},
|
||||
runnerTrace: {
|
||||
traceId: "trc_timeout_keep_context",
|
||||
sessionStatus: "timeout",
|
||||
lastEvent: {
|
||||
type: "session",
|
||||
label: "session:timeout",
|
||||
status: "timeout"
|
||||
}
|
||||
},
|
||||
error: {
|
||||
code: "codex_stdio_timeout",
|
||||
message: "请求超时"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(summary.kind, "timeout");
|
||||
assert.equal(summary.label, "等待超时");
|
||||
assert.equal(summary.tone, "blocked");
|
||||
assert.equal(summary.codeAgentStatus, "timeout");
|
||||
assert.equal(summary.sessionId, "ses_timeout_keep_context");
|
||||
assert.equal(summary.sessionStatus, "timeout");
|
||||
assert.equal(summary.lastTraceId, "trc_timeout_keep_context");
|
||||
});
|
||||
|
||||
test("maps HWLAB Skill CLI API control to distinct green state when ready", () => {
|
||||
const summary = classifyCodeAgentStatusSummary({
|
||||
latestMessage: {
|
||||
|
||||
@@ -873,7 +873,7 @@ assert.match(styles, /\.(?:status-dot|state-tag|badge)[^{]*{[^}]*max-width:\s*10
|
||||
assert.match(styles, /\.probe-card\s*{[^}]*min-width:\s*0;/s);
|
||||
assert.match(styles, /\.probe-card strong\s*{[^}]*line-height:\s*1\.2;[^}]*overflow-wrap:\s*anywhere;/s);
|
||||
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-m3-rows\s*{[\s\S]*?grid-template-columns:\s*1fr;/);
|
||||
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-trace,\s*\n\s*\.message-m3-evidence\s*{[\s\S]*?grid-column:\s*1;/);
|
||||
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-trace,\s*\n\s*\.message-m3-evidence(?:,\s*\n\s*\.message-actions)?\s*{[\s\S]*?grid-column:\s*1;/);
|
||||
for (const userFacingFunctionName of [
|
||||
"codeAgentStatusMessage",
|
||||
"codeAgentPromptText",
|
||||
|
||||
@@ -189,11 +189,11 @@ function assertBrowserWriteBoundaries({ app, html, artifactPublisher }) {
|
||||
.map((match) => match[1]);
|
||||
assert.deepEqual(
|
||||
uniqueSorted(postTargets),
|
||||
["/json-rpc", "/v1/agent/chat", M3_IO_CONTROL_ROUTE].sort(),
|
||||
"browser POST routes must be limited to read-only RPC envelope, agent chat, and M3 IO control"
|
||||
["/json-rpc", "/v1/agent/chat", "/v1/agent/chat/cancel", M3_IO_CONTROL_ROUTE].sort(),
|
||||
"browser POST routes must be limited to read-only RPC envelope, agent chat lifecycle, and M3 IO control"
|
||||
);
|
||||
|
||||
const controlWriteTargets = postTargets.filter((target) => target !== "/json-rpc" && target !== "/v1/agent/chat");
|
||||
const controlWriteTargets = postTargets.filter((target) => target !== "/json-rpc" && target !== "/v1/agent/chat" && target !== "/v1/agent/chat/cancel");
|
||||
assert.deepEqual(controlWriteTargets, [M3_IO_CONTROL_ROUTE], "same-origin /v1/m3/io is the only browser hardware write control route");
|
||||
assert.match(app, /fetchJson\("\/v1\/m3\/status"\)/u, "browser hardware status reads must use same-origin /v1/m3/status");
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ const READ_ONLY_RPC_METHODS = Object.freeze([
|
||||
]);
|
||||
const CONTROL_ROUTE = "/v1/m3/io";
|
||||
const STATUS_ROUTE = "/v1/m3/status";
|
||||
const CODE_AGENT_MUTATION_ROUTES = Object.freeze([
|
||||
"/v1/agent/chat",
|
||||
"/v1/agent/chat/cancel"
|
||||
]);
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.resolve(rootDir, "../..");
|
||||
@@ -91,7 +95,7 @@ export function runCloudWebM3ReadonlyContract() {
|
||||
|
||||
const mutationFetches = [...app.matchAll(/fetchJson\(\s*["']([^"']+)["'][\s\S]{0,220}?method:\s*["']POST["']/gu)]
|
||||
.map((match) => match[1])
|
||||
.filter((path) => path !== "/v1/agent/chat" && path !== CONTROL_ROUTE && path !== STATUS_ROUTE && path !== "/json-rpc");
|
||||
.filter((path) => !CODE_AGENT_MUTATION_ROUTES.includes(path) && path !== CONTROL_ROUTE && path !== STATUS_ROUTE && path !== "/json-rpc");
|
||||
assert.deepEqual(mutationFetches, [], "Cloud Web must not add POST mutation fetches outside Code Agent chat and M3 cloud-api control route");
|
||||
|
||||
const m3Milestone = gateSummary.milestones.find((item) => item.id === "M3");
|
||||
|
||||
@@ -839,6 +839,13 @@ h3 {
|
||||
border-color: rgba(231, 110, 94, 0.58);
|
||||
}
|
||||
|
||||
.message-card.status-timeout,
|
||||
.message-card.status-canceled,
|
||||
.message-card.status-error {
|
||||
border-color: rgba(231, 110, 94, 0.58);
|
||||
background: rgba(45, 28, 25, 0.74);
|
||||
}
|
||||
|
||||
.message-user {
|
||||
justify-self: end;
|
||||
border-color: rgba(212, 173, 67, 0.46);
|
||||
@@ -889,7 +896,8 @@ h3 {
|
||||
|
||||
.message-pending-context,
|
||||
.message-trace,
|
||||
.message-m3-evidence {
|
||||
.message-m3-evidence,
|
||||
.message-actions {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
@@ -911,6 +919,45 @@ h3 {
|
||||
background: rgba(15, 17, 16, 0.44);
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
background: rgba(20, 24, 23, 0.58);
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
.message-action {
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.message-action:hover,
|
||||
.message-action:focus-visible {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-3);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.message-action-cancel {
|
||||
border-color: rgba(231, 110, 94, 0.64);
|
||||
}
|
||||
|
||||
.message-action-status {
|
||||
min-width: 0;
|
||||
color: var(--dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-pending-head,
|
||||
.message-trace-head {
|
||||
min-width: 0;
|
||||
@@ -2076,7 +2123,8 @@ tbody tr:last-child td {
|
||||
|
||||
.message-pending-context,
|
||||
.message-trace,
|
||||
.message-m3-evidence {
|
||||
.message-m3-evidence,
|
||||
.message-actions {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user