diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 21ea1476..ddb38ace 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -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", diff --git a/internal/cloud/code-agent-session-registry.mjs b/internal/cloud/code-agent-session-registry.mjs index b3da386d..5fcea4ea 100644 --- a/internal/cloud/code-agent-session-registry.mjs +++ b/internal/cloud/code-agent-session-registry.mjs @@ -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({ diff --git a/internal/cloud/code-agent-trace-store.mjs b/internal/cloud/code-agent-trace-store.mjs index f66ca784..423f573d 100644 --- a/internal/cloud/code-agent-trace-store.mjs +++ b/internal/cloud/code-agent-trace-store.mjs @@ -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; diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index 999d9341..7ac56160 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -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, diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index ca1c353c..b8a7c849 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -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; diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index dc02e4eb..2bffcc88 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -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"); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index a970c912..1d92d95e 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -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) { diff --git a/scripts/src/m3-io-control-e2e.mjs b/scripts/src/m3-io-control-e2e.mjs index 672bf71e..8fab0a25 100644 --- a/scripts/src/m3-io-control-e2e.mjs +++ b/scripts/src/m3-io-control-e2e.mjs @@ -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" diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index d9ab9d71..fab0d949 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -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"; } diff --git a/web/hwlab-cloud-web/code-agent-status.mjs b/web/hwlab-cloud-web/code-agent-status.mjs index f9a220eb..5e6ae92c 100644 --- a/web/hwlab-cloud-web/code-agent-status.mjs +++ b/web/hwlab-cloud-web/code-agent-status.mjs @@ -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 diff --git a/web/hwlab-cloud-web/code-agent-status.test.mjs b/web/hwlab-cloud-web/code-agent-status.test.mjs index 26ba0c24..67526e6d 100644 --- a/web/hwlab-cloud-web/code-agent-status.test.mjs +++ b/web/hwlab-cloud-web/code-agent-status.test.mjs @@ -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: { diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 5eaf8b8e..2bc58fa7 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -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", diff --git a/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs b/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs index e525b9b8..a14b6dd8 100644 --- a/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs +++ b/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs @@ -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"); diff --git a/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs b/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs index 09cbd8e9..90472b29 100644 --- a/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs +++ b/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs @@ -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"); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 9c27c3bd..8b7bafe8 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -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; }