补齐 Code Agent 长会话生命周期控制

This commit is contained in:
Code Queue Review
2026-05-24 04:07:22 +00:00
parent 12d4847d09
commit 033a9da400
15 changed files with 1016 additions and 190 deletions
+45 -5
View File
@@ -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({
+5 -1
View File
@@ -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;
+86 -19
View File
@@ -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
View File
@@ -28,6 +28,7 @@ import {
} from "./code-agent-chat.mjs";
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.mjs";
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs";
import { 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;
+114 -1
View File
@@ -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");
+11 -5
View File
@@ -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) {
+1
View File
@@ -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
View File
@@ -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";
}
+16
View File
@@ -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
+76 -2
View File
@@ -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: {
+1 -1
View File
@@ -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");
+50 -2
View File
@@ -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;
}