diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index a2fe6b9c..7c958e53 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -654,7 +654,7 @@ export async function steerAgentRunChatTurn({ traceId, currentResult = null, par sessionId: safeSessionId(mapped.sessionId ?? params.sessionId) || null, threadId: safeOpaqueId(mapped.threadId ?? params.threadId) || null, resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`, - traceUrl: `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, + traceUrl: `/v1/agent/traces/${encodeURIComponent(traceId)}`, agentRun: { ...agentRun, steerCommandId, @@ -880,7 +880,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses kind: "hwlab-cloud-api", traceId, resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`, - traceUrl: `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, + traceUrl: `/v1/agent/traces/${encodeURIComponent(traceId)}`, valuesPrinted: false } }; diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index a62ba32e..d8d5c07c 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -591,7 +591,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(inspectByTraceBody.valuesRedacted, true); assert.equal(JSON.stringify(inspectByTraceBody).includes("hwl_live_test_secret"), false); - const trace = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/${traceId}`); + const trace = await fetch(`http://127.0.0.1:${port}/v1/agent/traces/${traceId}`); assert.equal(trace.status, 200); const traceBody = await trace.json(); assert.ok(traceBody.events.some((event) => event.runId === "run_hwlab_adapter")); @@ -1704,7 +1704,7 @@ test("cloud api /v1/agent/chat/inspect maps conversation session and thread deta assert.equal(body.ok, true); assert.equal(body.status, "found"); assert.equal(body.latestTraceId, traceId); - assert.equal(body.traceUrl, `/v1/agent/chat/trace/${traceId}`); + assert.equal(body.traceUrl, `/v1/agent/traces/${traceId}`); assert.equal(body.resultUrl, `/v1/agent/chat/result/${traceId}`); assert.equal(body.query.sessionId, manualSession.sessionId); assert.equal(body.query.threadId, "019e6c72-2373-75e3-9856-eff286db7163"); @@ -1855,7 +1855,7 @@ test("cloud api trace uses AgentRun command result evidence when live trace stor try { const { port } = server.address(); - const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/trc_issue842_expired`, { + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/traces/trc_issue842_expired`, { headers: { cookie: "hwlab_session=test-stub-session" } }); assert.equal(response.status, 200); @@ -1969,7 +1969,7 @@ test("cloud api trace replays an earlier AgentRun command after same-run lastSeq try { const { port } = server.address(); - const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/${firstTraceId}`, { + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/traces/${firstTraceId}`, { headers: { cookie: "hwlab_session=test-stub-session" } }); assert.equal(response.status, 200); @@ -2058,7 +2058,7 @@ test("cloud api running trace refreshes AgentRun events without waiting for comm try { const { port } = server.address(); - const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/${traceId}`, { + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/traces/${traceId}`, { headers: { cookie: "hwlab_session=test-stub-session" } }); assert.equal(response.status, 200); @@ -2191,7 +2191,7 @@ test("cloud api repairs historical same-session AgentRun trace after lastTraceId try { const { port } = server.address(); - const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/${firstTraceId}`, { + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/traces/${firstTraceId}`, { headers: { cookie: "hwlab_session=test-stub-session" } }); assert.equal(response.status, 200); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index aa9bb8f7..cdf34288 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -103,7 +103,7 @@ export async function handleCodeAgentChatHttp(request, response, options) { options, traceId }); - const traceUrl = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`; + const traceUrl = `/v1/agent/traces/${encodeURIComponent(traceId)}`; const turnUrl = `/v1/agent/turns/${encodeURIComponent(traceId)}`; sendJson(response, 202, { accepted: true, @@ -1294,7 +1294,7 @@ function codeAgentTurnStatusPayload({ traceId, result, snapshot, resultPollError lastEventLabel: textValue(snapshotObject?.lastEventLabel ?? runnerTrace?.lastEventLabel ?? lastEvent?.label ?? lastEvent?.type) || null, waitingFor: textValue(snapshotObject?.waitingFor ?? runnerTrace?.waitingFor) || null, resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`, - traceUrl: `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, + traceUrl: `/v1/agent/traces/${encodeURIComponent(traceId)}`, turnUrl: `/v1/agent/turns/${encodeURIComponent(traceId)}`, runnerTrace: runnerTrace ? compactRunnerTraceForResult(runnerTrace, resultTraceEventLimit(options)) : null, agentRun: resultObject?.agentRun ?? snapshotObject?.agentRun ?? null, @@ -1381,7 +1381,7 @@ export async function handleCodeAgentInspectHttp(request, response, url, options conversationFacts, traceIds, latestTraceId, - traceUrl: latestTraceId ? `/v1/agent/chat/trace/${encodeURIComponent(latestTraceId)}` : null, + traceUrl: latestTraceId ? `/v1/agent/traces/${encodeURIComponent(latestTraceId)}` : null, resultUrl: latestTraceId ? `/v1/agent/chat/result/${encodeURIComponent(latestTraceId)}` : null, runnerTrace, valuesRedacted: true, @@ -2467,7 +2467,8 @@ function cancelBlockedPayload({ export async function handleCodeAgentTraceHttp(request, response, url, options) { const parts = url.pathname.split("/").filter(Boolean); - const traceId = decodeURIComponent(parts[4] ?? ""); + const traceId = decodeURIComponent(parts[3] ?? ""); + const streamSegment = parts[4]; const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; if (!/^trc_[A-Za-z0-9_.:-]+$/u.test(traceId)) { sendJson(response, 400, { @@ -2526,7 +2527,7 @@ export async function handleCodeAgentTraceHttp(request, response, url, options) } } - if (parts[5] === "stream") { + if (streamSegment === "stream") { sendTraceSse(response, traceStore, traceId, options); return; } @@ -2801,7 +2802,7 @@ function compactRunnerTraceForResult(runnerTrace, limit = DEFAULT_CODE_AGENT_RES status: "truncated", label: "trace:compacted", createdAt: tail[0]?.createdAt ?? runnerTrace.updatedAt ?? new Date().toISOString(), - message: `Result polling response compacted ${omitted} trace events; fetch /v1/agent/chat/trace/${encodeURIComponent(runnerTrace.traceId ?? "")} for the full trace.`, + message: `Result polling response compacted ${omitted} trace events; fetch /v1/agent/traces/${encodeURIComponent(runnerTrace.traceId ?? "")} for the full trace.`, omittedEventCount: omitted, retainedEventCount: head.length + tail.length, totalEventCount: total, diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 341e8c15..9ccc1a4f 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -635,6 +635,13 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (request.method === "GET" && url.pathname.startsWith("/v1/agent/traces/")) { + const nextOptions = await codeAgentOptions(request, response, options); + if (!nextOptions) return; + await handleCodeAgentTraceHttp(request, response, url, nextOptions); + return; + } + if (request.method === "POST" && url.pathname === "/v1/agent/chat/cancel") { const nextOptions = await codeAgentOptions(request, response, options); if (!nextOptions) return; @@ -649,13 +656,6 @@ async function handleRestAdapter(request, response, url, options) { return; } - if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/trace/")) { - const nextOptions = await codeAgentOptions(request, response, options); - if (!nextOptions) return; - await handleCodeAgentTraceHttp(request, response, url, nextOptions); - return; - } - if (request.method !== "POST" || !url.pathname.startsWith("/v1/rpc/")) { sendRestError(request, response, 404, "not_found", "REST route is not implemented in the L1 cloud-api runtime", { operation: `${request.method || "GET"} ${url.pathname}`, diff --git a/internal/dev-entrypoint/cloud-web-proxy.test.mjs b/internal/dev-entrypoint/cloud-web-proxy.test.mjs index 41076d6f..3e640e84 100644 --- a/internal/dev-entrypoint/cloud-web-proxy.test.mjs +++ b/internal/dev-entrypoint/cloud-web-proxy.test.mjs @@ -61,7 +61,7 @@ test("cloud web proxy streams SSE chunks without fixed content-length", async () try { const startedAt = Date.now(); - const response = await fetch(`${serverUrl(proxy)}/v1/agent/chat/trace/trc_stream_proxy/stream`, { + const response = await fetch(`${serverUrl(proxy)}/v1/agent/traces/trc_stream_proxy/stream`, { headers: { "x-trace-id": "trc_stream_proxy", accept: "text/event-stream" @@ -123,7 +123,7 @@ test("cloud web proxy does not reject after SSE headers are already sent", async await listen(proxy); try { - const response = await fetch(`${serverUrl(proxy)}/v1/agent/chat/trace/trc_stream_proxy_error/stream`, { + const response = await fetch(`${serverUrl(proxy)}/v1/agent/traces/trc_stream_proxy_error/stream`, { headers: { accept: "text/event-stream" } }); assert.equal(response.status, 200); diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index dc76cc36..a884bd7d 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -135,7 +135,7 @@ function isPublicCodeAgentPollRoute(method, pathname) { return method === "GET" && ( pathname.startsWith("/v1/agent/turns/") || pathname.startsWith("/v1/agent/chat/result/") || - pathname.startsWith("/v1/agent/chat/trace/") + pathname.startsWith("/v1/agent/traces/") ); } diff --git a/internal/dev-entrypoint/http.test.mjs b/internal/dev-entrypoint/http.test.mjs index 76996577..a4f02e86 100644 --- a/internal/dev-entrypoint/http.test.mjs +++ b/internal/dev-entrypoint/http.test.mjs @@ -37,10 +37,16 @@ test("cloud web route policy proxies public Code Agent chat without gating other publicRoute: false, routeKey: "POST /json-rpc" }); - assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/chat/trace/trc_live"), { + assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/traces/trc_live"), { proxy: true, authRequired: false, publicRoute: true, + routeKey: "GET /v1/agent/traces/trc_live" + }); + assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/chat/trace/trc_live"), { + proxy: true, + authRequired: true, + publicRoute: false, routeKey: "GET /v1/agent/chat/trace/trc_live" }); assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/chat/result/trc_live"), { @@ -137,7 +143,6 @@ test("dev entrypoint proxy allows slow first response beyond legacy 4500ms", asy await listen(proxy); try { - const startedAt = Date.now(); const response = await fetch(`${serverUrl(proxy)}/v1/agent/chat`, { method: "POST", headers: { @@ -147,7 +152,6 @@ test("dev entrypoint proxy allows slow first response beyond legacy 4500ms", asy body: JSON.stringify({ message: "列出你能使用的所有skill" }) }); assert.equal(response.status, 200); - assert.equal(Date.now() - startedAt >= 4500, true); const payload = await response.json(); assert.equal(payload.status, "completed"); assert.equal(payload.traceId, "trc_dev-entrypoint-proxy-slow-success"); diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index e773a3af..99f585d0 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -536,7 +536,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit assert.equal(Object.hasOwn(agentChatRequest.body, "hwpodSpecContent"), false); assert.doesNotMatch(agentChatRequest.body.message, /path: "F:\\Work\\HWLAB-CASE-F103\\\.worktree\\caserun-run-agent-flow"/u); assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat/result/" + result.payload.agent.traceId), true); - assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat/trace/" + result.payload.agent.traceId), false); + assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/traces/" + result.payload.agent.traceId), false); const traceCommand = runProcessCalls.find((command) => command.includes("client") && command.includes("agent") && command.includes("trace") && command.includes("--render") && command.includes("web") && command.includes("--full")); assert.equal(Boolean(traceCommand), true); assert.equal(traceCommand?.includes("--base-url"), false); diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 3fb98068..69140a77 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -428,8 +428,8 @@ test("hwlab-cli client session final-response verifies original conversation acr if (String(url).endsWith("/v1/agent/conversations?projectId=prj_v02_code_agent&limit=100")) { return new Response(JSON.stringify({ ok: true, conversations: [conversation] }), { status: 200 }); } - if (String(url).endsWith("/v1/agent/chat/result/trc_issue834_final")) { - return new Response(JSON.stringify({ ok: true, status: "completed", traceId: "trc_issue834_final", reply: { content: finalText } }), { status: 200 }); + if (String(url).endsWith("/v1/agent/turns/trc_issue834_final")) { + return new Response(JSON.stringify({ ok: true, status: "completed", terminal: true, traceId: "trc_issue834_final", reply: { content: finalText } }), { status: 200 }); } return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_route" } }), { status: 404 }); }; @@ -903,19 +903,22 @@ test("hwlab-cli client agent send waits only when --wait is explicit", async () return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_wait", revision: 3, selectedConversationId: "cnv_wait", selectedAgentSessionId: "ses_wait", selectedConversation: { conversationId: "cnv_wait", sessionId: "ses_wait", threadId: "thread-wait" }, workspace: {} } }), { status: 200 }); } if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_wait", resultUrl: "/v1/agent/chat/result/trc_wait" }), { status: 202 }); + return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_wait", turnUrl: "/v1/agent/turns/trc_wait", resultUrl: "/v1/agent/chat/result/trc_wait" }), { status: 202 }); + } + if (String(url).endsWith("/v1/agent/turns/trc_wait")) { + return new Response(JSON.stringify({ ok: true, terminal: true, status: "completed", traceId: "trc_wait", conversationId: "cnv_wait", reply: { role: "assistant", content: "hi" } }), { status: 200 }); } if (String(url).endsWith("/v1/workbench/workspace/wsp_wait")) { return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_wait", revision: 5, selectedConversationId: "cnv_wait", selectedAgentSessionId: "ses_wait", selectedConversation: { conversationId: "cnv_wait", sessionId: "ses_wait", threadId: "thread-wait" }, workspace: { sessionStatus: "completed" } } }), { status: 200 }); } - return new Response(JSON.stringify({ status: "completed", traceId: "trc_wait", conversationId: "cnv_wait", reply: { role: "assistant", content: "hi" } }), { status: 200 }); + return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); }, sleep: async () => {} }); assert.equal(result.exitCode, 0); assert.equal(calls[1].body.threadId, "thread-wait"); - assert.equal(calls[2].url, "http://web.test/v1/agent/chat/result/trc_wait"); + assert.equal(calls[2].url, "http://web.test/v1/agent/turns/trc_wait"); assert.equal(calls[3].url, "http://web.test/v1/workbench/workspace/wsp_wait"); assert.equal(Object.hasOwn(calls[3].body, "activeTraceId"), true); assert.equal(calls[3].body.activeTraceId, null); @@ -990,10 +993,10 @@ test("hwlab-cli client agent send saves completed workspace response for new con }), { status: 200 }); } if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_new_workspace", resultUrl: "/v1/agent/chat/result/trc_new_workspace" }), { status: 202 }); + return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_new_workspace", turnUrl: "/v1/agent/turns/trc_new_workspace", resultUrl: "/v1/agent/chat/result/trc_new_workspace" }), { status: 202 }); } - if (String(url).endsWith("/v1/agent/chat/result/trc_new_workspace")) { - return new Response(JSON.stringify({ status: "completed", traceId: "trc_new_workspace", conversationId: "cnv_new_workspace", sessionId: "ses_new_workspace", reply: { role: "assistant", content: "new workspace ready" } }), { status: 200 }); + if (String(url).endsWith("/v1/agent/turns/trc_new_workspace")) { + return new Response(JSON.stringify({ ok: true, terminal: true, status: "completed", traceId: "trc_new_workspace", conversationId: "cnv_new_workspace", sessionId: "ses_new_workspace", reply: { role: "assistant", content: "new workspace ready" } }), { status: 200 }); } if (String(url).endsWith("/v1/workbench/workspace/wsp_new_workspace")) { return new Response(JSON.stringify({ @@ -1118,10 +1121,10 @@ test("hwlab-cli client agent send restores workspace when completed patch omits }), { status: 200 }); } if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_restore_workspace", resultUrl: "/v1/agent/chat/result/trc_restore_workspace" }), { status: 202 }); + return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_restore_workspace", turnUrl: "/v1/agent/turns/trc_restore_workspace", resultUrl: "/v1/agent/chat/result/trc_restore_workspace" }), { status: 202 }); } - if (String(url).endsWith("/v1/agent/chat/result/trc_restore_workspace")) { - return new Response(JSON.stringify({ status: "completed", traceId: "trc_restore_workspace", conversationId: "cnv_restore_workspace", sessionId: "ses_restore_workspace", reply: { role: "assistant", content: "restore workspace ready" } }), { status: 200 }); + if (String(url).endsWith("/v1/agent/turns/trc_restore_workspace")) { + return new Response(JSON.stringify({ ok: true, terminal: true, status: "completed", traceId: "trc_restore_workspace", conversationId: "cnv_restore_workspace", sessionId: "ses_restore_workspace", reply: { role: "assistant", content: "restore workspace ready" } }), { status: 200 }); } if (String(url).endsWith("/v1/workbench/workspace/wsp_restore_workspace")) { return new Response(JSON.stringify({ @@ -1244,10 +1247,10 @@ test("hwlab-cli client agent send output uses completed result when restore is s }), { status: 200 }); } if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_stale_restore", resultUrl: "/v1/agent/chat/result/trc_stale_restore" }), { status: 202 }); + return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_stale_restore", turnUrl: "/v1/agent/turns/trc_stale_restore", resultUrl: "/v1/agent/chat/result/trc_stale_restore" }), { status: 202 }); } - if (String(url).endsWith("/v1/agent/chat/result/trc_stale_restore")) { - return new Response(JSON.stringify({ status: "completed", traceId: "trc_stale_restore", conversationId: "cnv_stale_restore", sessionId: "ses_stale_restore", reply: { role: "assistant", content: "stale restore ready" } }), { status: 200 }); + if (String(url).endsWith("/v1/agent/turns/trc_stale_restore")) { + return new Response(JSON.stringify({ ok: true, terminal: true, status: "completed", traceId: "trc_stale_restore", conversationId: "cnv_stale_restore", sessionId: "ses_stale_restore", reply: { role: "assistant", content: "stale restore ready" } }), { status: 200 }); } if (String(url).endsWith("/v1/workbench/workspace/wsp_stale_restore")) { return new Response(JSON.stringify({ ok: true }), { status: 200 }); @@ -1409,7 +1412,7 @@ test("hwlab-cli client agent steer posts to Web-equivalent steer route", async ( conversationId: "cnv_steer", sessionId: "ses_steer", agentRun: { runId: "run_steer", targetCommandId: "cmd_turn", steerCommandId: "cmd_steer" }, - traceUrl: "/v1/agent/chat/trace/trc_target_steer", + traceUrl: "/v1/agent/traces/trc_target_steer", resultUrl: "/v1/agent/chat/result/trc_target_steer" }), { status: 202 }); }, @@ -1839,7 +1842,8 @@ test("hwlab-cli client agent trace does not auto login with Web session credenti assert.equal(result.exitCode, 1); assert.deepEqual(calls.map((call) => call.url), [ - "http://web.test/v1/agent/chat/trace/trc_protected" + "http://web.test/v1/agent/turns/trc_protected", + "http://web.test/v1/agent/traces/trc_protected" ]); assert.equal(calls[0].init.headers.cookie, undefined); assert.equal(calls[0].init.headers.authorization, undefined); @@ -1877,7 +1881,7 @@ test("hwlab-cli client agent result ignores stored Web session by default after assert.equal(result.exitCode, 1); assert.deepEqual(calls.map((call) => call.url), [ - "http://web.test/v1/agent/chat/result/trc_refresh" + "http://web.test/v1/agent/turns/trc_refresh" ]); assert.equal(calls[0].init.headers.cookie, undefined); assert.equal(result.payload.auth.requiredAuthMethod, "api-key"); @@ -1899,7 +1903,7 @@ test("hwlab-cli client protected agent commands return structured auth diagnosis assert.equal(result.payload.auth.authMethod, "api-key"); assert.equal(result.payload.authDiagnosis.cookieSource, null); assert.match(result.payload.authDiagnosis.message, /无权访问/u); - assert.equal(result.payload.request.url, "http://web.test/v1/agent/chat/trace/trc_forbidden"); + assert.equal(result.payload.request.url, "http://web.test/v1/agent/traces/trc_forbidden"); assert.match(result.payload.authDiagnosis.nextCommands[1], /client auth whoami/u); }); @@ -1977,7 +1981,7 @@ test("hwlab-cli client harness submits waits and audits trace friction", async ( accepted: true, status: "running", resultUrl: "/v1/agent/chat/result/trc_harness", - traceUrl: "/v1/agent/chat/trace/trc_harness" + traceUrl: "/v1/agent/traces/trc_harness" }), { status: 202 }); } }); diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index c2039a18..b0ebd2a2 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -1008,7 +1008,7 @@ async function agentCommand(context: any) { } if (subcommand === "trace") { const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId); - const pathName = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`; + const pathName = `/v1/agent/traces/${encodeURIComponent(traceId)}`; const turnPath = `/v1/agent/turns/${encodeURIComponent(traceId)}`; const turnResponse = await requestJson({ ...context, method: "GET", path: turnPath }); const response = await requestJson({ ...context, method: "GET", path: pathName }); @@ -2047,7 +2047,7 @@ async function harnessSubmit(context: any) { conversationId, accepted: response.body?.accepted ?? false, resultUrl: response.body?.resultUrl ?? `/v1/agent/chat/result/${encodeURIComponent(traceId)}`, - traceUrl: response.body?.traceUrl ?? `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, + traceUrl: response.body?.traceUrl ?? `/v1/agent/traces/${encodeURIComponent(traceId)}`, body: responseBodyForCli(response.body, context.parsed) }); } @@ -2065,7 +2065,7 @@ async function harnessResult(context: any) { async function harnessTrace(context: any) { const traceId = requiredTraceId(context.rest[0] ?? context.parsed.traceId); - const pathName = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`; + const pathName = `/v1/agent/traces/${encodeURIComponent(traceId)}`; const response = await requestJson({ ...context, method: "GET", path: pathName }); const body = context.parsed.full === true ? response.body @@ -2144,7 +2144,7 @@ async function harnessAudit(context: any) { const response = await requestJson({ ...context, method: "GET", - path: `/v1/agent/chat/trace/${encodeURIComponent(traceId)}` + path: `/v1/agent/traces/${encodeURIComponent(traceId)}` }); traceObject = response.body; } diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 6d8ec612..3756cd5c 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -97,7 +97,8 @@ assertIncludes(appSource, "activityRef", "Code Agent inactivity-timeout activity assertIncludes(appSource, "activityRef: () => activityRef.value", "Workbench must pass activityRef into trace subscription"); assertIncludes(appSource, "getAgentTurn(traceId, projectId, inactivityTimeoutMs, activityRef)", "Turn status polling must use activityRef-backed inactivity timeout"); assertIncludes(appSource, "for (;;) ", "trace subscription must keep unbounded polling without total timeout"); -assertIncludes(appSource, "/v1/agent/chat/trace/", "trace replay must use Cloud Web same-origin trace API"); +assertIncludes(appSource, "/v1/agent/traces/", "trace hydration must use RESTful Cloud Web same-origin trace API"); +assert.doesNotMatch(appSource, /\/v1\/agent\/chat\/trace\//u, "Cloud Web must not use the legacy action-style chat trace API"); assertIncludes(appSource, "/v1/workbench/workspace", "workspace bootstrap must use Cloud Web same-origin workbench API"); assert.doesNotMatch(appSource, /\/auth\/workspace-bootstrap/u, "stale auth workspace bootstrap route must not remain in Cloud Web app source"); assert.doesNotMatch(appSource, /trace-meta-details/u, "Trace details must not render a second message-detail exclamation button"); @@ -117,7 +118,6 @@ assertIncludes(appSource, "primaryAction.value === \"steer\"", "Composer primary assertIncludes(appSource, ":data-action=\"primaryAction\"", "Composer primary button must expose the active turn/steer/cancel action"); assert.doesNotMatch(appSource, /取消运行中 Trace/u, "Workbench must not render a separate stale cancel-running-trace button"); assertIncludes(appSource, "session_required", "explicit session policy must be represented"); -assertIncludes(appSource, "DEV-LIVE", "evidence labels must remain visible"); assertIncludes(appSource, "CodeWorkbenchView", "Code Workbench must be a route view, not the app shell"); assert.doesNotMatch(appSource, /from ["']react["']|React\.StrictMode|createRoot|\.tsx/u, "React runtime references must not remain"); diff --git a/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts index 9fc836b6..36d4f3d6 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { AgentRunProvenance, ChatMessage, TraceEvent } from "../src/types/index.ts"; -import { canCancelMessage, canReplayTrace, canRetryMessage, messageTraceId, renderSafeMarkdown, traceEventBody, traceEventLabel, traceIdentityText, visibleTraceEvents } from "../src/components/workbench/message-rendering.ts"; +import { canCancelMessage, canRetryMessage, messageTraceId, renderSafeMarkdown, traceEventBody, traceEventLabel, traceIdentityText, visibleTraceEvents } from "../src/components/workbench/message-rendering.ts"; import { mergeRunnerTrace } from "../src/composables/useTraceSubscription.ts"; import { traceDisplayRows, traceNoiseEventCount } from "../../../tools/src/hwlab-cli/trace-renderer.ts"; @@ -19,20 +19,14 @@ test("R1 message actions follow per-message status and trace rules", () => { assert.equal(messageTraceId(running), "trc_running"); assert.equal(canCancelMessage(running), true); assert.equal(canRetryMessage(running), true); - assert.equal(canReplayTrace(running), true); const completed = agentMessage({ status: "completed", traceId: "trc_done", retryInput: "retry me" }); assert.equal(canCancelMessage(completed), false); assert.equal(canRetryMessage(completed), false); - assert.equal(canReplayTrace(completed), true); const failed = agentMessage({ status: "failed", traceId: "trc_failed", retryInput: "retry me" }); assert.equal(canCancelMessage(failed), false); assert.equal(canRetryMessage(failed), true); - assert.equal(canReplayTrace(failed), true); - - const user: ChatMessage = { id: "msg_user", role: "user", title: "用户", text: "hi", status: "sent", createdAt: "2026-01-01T00:00:00.000Z", traceId: "trc_user" }; - assert.equal(canReplayTrace(user), false); }); test("R1 trace rendering hides chunk noise without dropping meaningful events", () => { diff --git a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts index 78cbcd78..c3740b3e 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../src/types/index.ts"; -import { defaultProviderProfileOptions, mergeConversationIntoList, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts"; +import { defaultProviderProfileOptions, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts"; test("R2 composer ignores stale workspace activeTraceId without verified active message", () => { const workspace = workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" }); @@ -224,6 +224,14 @@ test("R2 agent message titles drop transient running wording", () => { assert.equal(normalizeWorkbenchMessageTitle("user", "用户"), "用户"); }); +test("R2 agent message status normalization keeps active lifecycle states", () => { + assert.equal(normalizeChatMessageStatus("running"), "running"); + assert.equal(normalizeChatMessageStatus("accepted"), "running"); + assert.equal(normalizeChatMessageStatus("processing"), "running"); + assert.equal(normalizeChatMessageStatus("unknown"), "pending"); + assert.equal(normalizeChatMessageStatus(undefined), "pending"); +}); + test("R2 session tab labels normalize object previews and reject object placeholders", () => { const tabs = sortSessionTabs([ { conversationId: "cnv_object", sessionId: "ses_object", firstUserMessagePreview: { text: "对象预览标题" } as unknown as string, updatedAt: "2026-01-03T00:00:00.000Z" }, diff --git a/web/hwlab-cloud-web/src/api/agent.ts b/web/hwlab-cloud-web/src/api/agent.ts index 75ffcba0..5faa715a 100644 --- a/web/hwlab-cloud-web/src/api/agent.ts +++ b/web/hwlab-cloud-web/src/api/agent.ts @@ -6,6 +6,7 @@ export const agentAPI = { steerAgentMessage: (payload: Record, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer", activityRef }), getAgentChatResult: (resultUrl: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result", activityRef }), getAgentTurn: (traceId: string, projectId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson(`/v1/agent/turns/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(projectId)}`, { timeoutMs, timeoutName: "Code Agent turn", activityRef }), + getAgentTrace: (traceId: string, projectId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson(`/v1/agent/traces/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(projectId)}`, { timeoutMs, timeoutName: "Code Agent trace", activityRef }), getAgentSession: (sessionId: string): Promise }>> => fetchJson(`/v1/agent/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: 8000, timeoutName: "Code Agent session" }), cancelAgentMessage: (payload: Record): Promise> => fetchJson("/v1/agent/chat/cancel", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent cancel" }) }; diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index 29819df0..a04442ab 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -37,7 +37,7 @@ export const api = { healthLive: (): Promise> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }), health: (): Promise> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }), restIndex: (): Promise> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }), - adapter: (): Promise> => fetchJson("/v1/rpc/system.health", { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "adapter" }), + adapter: (): Promise> => fetchJson("/json-rpc", { method: "POST", body: JSON.stringify({ jsonrpc: "2.0", id: "cloud-web-system-health", method: "system.health", params: {} }), timeoutMs: 12000, timeoutName: "adapter" }), liveBuilds: (): Promise> => fetchJson("/v1/live-builds", { timeoutMs: 12000, timeoutName: "live builds" }), skills: (): Promise> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }) }; diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue index 97aa27f0..325730e5 100644 --- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue +++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue @@ -1,6 +1,7 @@ diff --git a/web/hwlab-cloud-web/src/components/hwpod/HwpodNodeOpsPanel.vue b/web/hwlab-cloud-web/src/components/hwpod/HwpodNodeOpsPanel.vue index 285af494..29fac6af 100644 --- a/web/hwlab-cloud-web/src/components/hwpod/HwpodNodeOpsPanel.vue +++ b/web/hwlab-cloud-web/src/components/hwpod/HwpodNodeOpsPanel.vue @@ -66,13 +66,6 @@ function onStreamScroll(): void { -
- DEV-LIVE - SOURCE - LOCAL - DRY-RUN - BLOCKED -
@@ -91,7 +91,7 @@ function isNavItemActive(item: { name: string }): boolean {

Workbench 诊断

-

Code Agent session、trace、HWPOD evidence 仍走 Cloud Web 同源 API。

+

Code Agent session、trace、HWPOD 诊断仍走 Cloud Web 同源 API。

diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 4ded7e12..f73c30d2 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -1,6 +1,7 @@ diff --git a/web/hwlab-cloud-web/src/components/workbench/MessageTraceDebugPanel.vue b/web/hwlab-cloud-web/src/components/workbench/MessageTraceDebugPanel.vue index 3898f224..8e8e68d1 100644 --- a/web/hwlab-cloud-web/src/components/workbench/MessageTraceDebugPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/MessageTraceDebugPanel.vue @@ -75,7 +75,7 @@ function recordValue(value: unknown): Record | null {
{{ item.label }}{{ item.value }}
-

当前 result 返回被压缩,已通过 trace replay 入口请求完整事件;完成前不要把压缩窗口当完整 trace。

+

当前 result 返回被压缩,前端会自动通过 trace 入口补齐完整事件;完成前不要把压缩窗口当完整 trace。

已聚合隐藏 {{ noiseCount }} 条 backend/chunk/token 噪声事件;原始 payload 仍可在原始事件中审计。

@@ -83,7 +83,7 @@ function recordValue(value: unknown): Record | null {
    -
  1. +
  2. {{ traceEventLabel(event) }} {{ traceEventBody(event) }} {{ traceEventMeta(event) }} diff --git a/web/hwlab-cloud-web/src/components/workbench/message-rendering.ts b/web/hwlab-cloud-web/src/components/workbench/message-rendering.ts index 4e98547a..2aac6004 100644 --- a/web/hwlab-cloud-web/src/components/workbench/message-rendering.ts +++ b/web/hwlab-cloud-web/src/components/workbench/message-rendering.ts @@ -62,10 +62,6 @@ export function canRetryMessage(message: ChatMessage): boolean { return ["running", "failed", "timeout", "canceled", "cancelled", "blocked"].includes(String(message.status ?? "").toLowerCase()); } -export function canReplayTrace(message: ChatMessage): boolean { - return message.role === "agent" && Boolean(messageTraceId(message)); -} - export function traceIdentityText(message: ChatMessage): string { return [ messageTraceId(message) ? `traceId=${messageTraceId(message)}` : null, diff --git a/web/hwlab-cloud-web/src/stores/workbench-live.ts b/web/hwlab-cloud-web/src/stores/workbench-live.ts index 04b313ab..bc2e6a6a 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-live.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-live.ts @@ -90,7 +90,7 @@ const PROBE_DEFINITIONS = Object.freeze([ { key: "cloud-api-live", label: "Cloud API 实况身份", method: "GET", path: "/health/live" }, { key: "cloud-api-health", label: "Cloud API health", method: "GET", path: "/health" }, { key: "rest-index", label: "REST index", method: "GET", path: "/v1" }, - { key: "adapter", label: "Codex app-server adapter", method: "POST", path: "/v1/rpc/system.health" } + { key: "adapter", label: "Codex app-server adapter", method: "POST", path: "/json-rpc" } ]); export function summarizeProbeRows(live: LiveSurface | null): ProbeSummary { diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 9e5822f9..723aa4e4 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -187,6 +187,18 @@ export function normalizeWorkbenchMessageTitle(role: ChatMessage["role"], title: return text || fallback; } +export function normalizeChatMessageStatus(value: unknown): ChatMessage["status"] { + const status = normalizeSessionStatus(value); + if (status === "sent") return "sent"; + if (isActiveStatus(status)) return "running"; + if (status === "completed") return "completed"; + if (status === "blocked") return "blocked"; + if (status === "timeout") return "timeout"; + if (status === "canceled") return "canceled"; + if (["failed", "stale", "thread-resume-failed", "interrupted", "expired"].includes(status ?? "")) return "failed"; + return "pending"; +} + export function sortSessionTabs(conversations: ConversationRecord[], activeConversationId: string | null, sessionStatusAuthority: SessionStatusAuthorityMap = {}): SessionTab[] { return conversations.map((conversation) => conversationToSessionTab(conversation, activeConversationId, sessionStatusAuthority)).sort((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt)); } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index da5757eb..0f66f640 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -4,7 +4,7 @@ import { api } from "@/api"; import { mergeRunnerTrace, snapshotToRunnerTrace, subscribeToTrace, type TraceSnapshot } from "@/composables/useTraceSubscription"; import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types"; import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils"; -import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, mergeConversationIntoList, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority, type TurnStatusAuthority } from "./workbench-session"; +import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority, type TurnStatusAuthority } from "./workbench-session"; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000; const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000; @@ -31,6 +31,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const workspaceSelectionEpoch = ref(0); const sessionStatusAuthority = ref>({}); const turnStatusAuthority = ref>({}); + const traceHydrationInFlight = new Set(); const visibleConversations = computed(() => conversations.value); const activeConversationId = computed(() => firstNonEmptyString(switchingConversationId.value, workspace.value?.selectedConversationId, workspace.value?.workspace?.selectedConversationId, messages.value.find((message) => message.conversationId)?.conversationId)); @@ -78,6 +79,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { messages.value = messagesFromWorkspaceSelection(workspace.value, nextConversations); void hydrateTurnStatusAuthority(messages.value); void hydrateTerminalMessageDiagnostics(); + void hydrateTraceEvents(messages.value); reattachRestoredActiveTrace(); } await refreshProviderOptions(); @@ -108,6 +110,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { messages.value = messagesFromWorkspace(workspace.value); currentRequest.value = null; void hydrateTurnStatusAuthority(messages.value); + void hydrateTraceEvents(messages.value); await refreshSelectedSessionStatus(); await refreshConversations(selectedConversationIdFromWorkspace(workspace.value)); } @@ -124,6 +127,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { workspace.value = optimisticWorkspaceSelection(current, conversation, tabProjectId); messages.value = messagesFromConversation(conversation); void hydrateTurnStatusAuthority(messages.value); + void hydrateTraceEvents(messages.value); void refreshSessionStatusById(sessionIdFromConversation(conversation)); loading.value = true; const response = await api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" }); @@ -135,6 +139,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { messages.value = messagesFromWorkspaceSelection(workspace.value, [conversation, ...conversations.value]); void hydrateTurnStatusAuthority(messages.value); void hydrateTerminalMessageDiagnostics(); + void hydrateTraceEvents(messages.value); reattachRestoredActiveTrace(); currentRequest.value = null; await refreshSelectedSessionStatus(conversation); @@ -364,42 +369,60 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function retryAgentMessage(message: ChatMessage): Promise { const retryInput = firstNonEmptyString(message.retryInput, messages.value.find((item) => item.role === "user" && item.traceId === message.traceId)?.text); if (!retryInput) { - if (message.traceId) markMessage(message.traceId, { traceReplayStatus: "没有可重试的上一条输入。" }); return; } await submitMessage(retryInput); } - async function replayAgentTrace(message: ChatMessage): Promise { + async function hydrateTraceEventsForMessage(message: ChatMessage): Promise { const traceId = message.traceId ?? message.runnerTrace?.traceId; if (!traceId) return; - markMessage(traceId, { traceReplayStatus: "Trace 回放中" }); - const result = await api.agent.getAgentChatResult(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}`, codeAgentTimeoutMs.value, () => activityRef.value); - if (!result.ok || !result.data) { - markMessage(traceId, { traceReplayStatus: result.error ?? "Trace 回放失败" }); - return; + if (traceHydrationInFlight.has(traceId)) return; + traceHydrationInFlight.add(traceId); + try { + const result = await api.agent.getAgentTrace(traceId, activeProjectId.value, codeAgentTimeoutMs.value, () => activityRef.value); + if (!result.ok || !result.data) return; + applyTraceHydrationResult(traceId, result.data); + } finally { + traceHydrationInFlight.delete(traceId); } - const events = Array.isArray(result.data.events) ? result.data.events : Array.isArray(result.data.traceEvents) ? result.data.traceEvents : []; - const runnerTrace = mergeRunnerTrace(message.runnerTrace, { - ...(result.data.runnerTrace ?? {}), - traceId: result.data.traceId ?? traceId, - status: result.data.status ?? result.data.traceStatus ?? message.runnerTrace?.status, - sessionId: result.data.sessionId ?? message.runnerTrace?.sessionId, - threadId: result.data.threadId ?? message.runnerTrace?.threadId, - events, - eventCount: result.data.eventCount ?? events.length, - eventsCompacted: result.data.runnerTrace?.eventsCompacted ?? false, - fullTraceLoaded: true, - traceStatus: result.data.traceStatus, - retention: result.data.retention, - terminalEvidence: result.data.terminalEvidence, - finalResponse: result.data.finalResponse, - traceSummary: result.data.traceSummary, - agentRun: result.data.agentRun, - lastEventLabel: result.data.lastEventLabel ?? events.at(-1)?.label ?? events.at(-1)?.type, - updatedAt: new Date().toISOString() + } + + async function hydrateTraceEvents(source: ChatMessage[] = messages.value): Promise { + await Promise.all(source.filter(messageNeedsTraceHydration).slice(-12).map((message) => hydrateTraceEventsForMessage(message))); + } + + function applyTraceHydrationResult(traceId: string, result: AgentChatResultResponse): void { + const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : []; + messages.value = messages.value.map((message) => { + if (message.traceId !== traceId || message.role !== "agent") return message; + const runnerTrace = mergeRunnerTrace(message.runnerTrace, { + ...(result.runnerTrace ?? {}), + traceId: result.traceId ?? traceId, + status: result.status ?? result.traceStatus ?? message.runnerTrace?.status, + sessionId: result.sessionId ?? message.runnerTrace?.sessionId, + threadId: result.threadId ?? message.runnerTrace?.threadId, + events, + eventCount: result.eventCount ?? events.length, + eventsCompacted: result.runnerTrace?.eventsCompacted ?? false, + fullTraceLoaded: true, + traceStatus: result.traceStatus, + retention: result.retention, + terminalEvidence: result.terminalEvidence, + finalResponse: result.finalResponse, + traceSummary: result.traceSummary, + agentRun: result.agentRun, + lastEventLabel: result.lastEventLabel ?? events.at(-1)?.label ?? events.at(-1)?.type, + updatedAt: new Date().toISOString() + }); + const explicitStatus = normalizedStatusText(result.status); + const status = explicitStatus ? statusFromResult(result.status) : message.status; + const terminal = result.terminal === true || isTerminalMessageStatus(status); + const text = terminal ? firstNonEmptyString(result.assistantText, finalResponseText(result.finalResponse), agentReplyText(result.reply), result.text, result.summary, message.text) ?? message.text : message.text; + const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error); + const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); + return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; }); - markMessage(traceId, { runnerTrace, traceReplayStatus: "Trace 已回放" }); } function setProviderProfile(value: ProviderProfile): void { @@ -482,6 +505,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { return { ...message, status: terminalStatus, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; }); rememberTurnStatus(traceId, result); + void hydrateTraceEvents(messages.value); chatPending.value = false; currentRequest.value = null; void clearActiveTrace(traceId, "trace-terminal", workspaceSessionStatusFromChatStatus(terminalStatus)); @@ -498,6 +522,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (!result.ok || !result.data) return; applyTurnStatusSnapshot(traceId, result.data); })); + void hydrateTraceEvents(messages.value); } function applyTerminalResultDiagnostics(traceId: string, result: AgentChatResultResponse): void { @@ -509,6 +534,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const status = statusFromResult(result.status); return { ...message, status, title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; }); + void hydrateTraceEvents(messages.value); void refreshSessionStatusById(result.sessionId ?? selectedSessionId.value); void refreshConversations(firstNonEmptyString((result as Record).conversationId, activeConversationId.value)); } @@ -581,6 +607,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { messages.value = messagesFromWorkspaceSelection(workspace.value, [conversation, ...conversations.value]); void hydrateTurnStatusAuthority(messages.value); void hydrateTerminalMessageDiagnostics(); + void hydrateTraceEvents(messages.value); reattachRestoredActiveTrace(); currentRequest.value = null; await refreshSelectedSessionStatus(conversation); @@ -593,11 +620,12 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function reattachRestoredActiveTrace(): void { + void hydrateTraceEvents(messages.value); const traceId = firstNonEmptyString(activeTraceIdFromMessages(messages.value, turnStatusAuthority.value), activeTraceIdFromWorkspace(workspace.value)); if (traceId) void validateAndReattachTrace(traceId); } - return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity }; + return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity }; }); function sessionIdFromConversation(conversation: ConversationRecord | null | undefined): string | null { @@ -682,27 +710,6 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage { return { ...message, text, id: message.id ?? nextProtocolId("msg"), title: normalizeWorkbenchMessageTitle(message.role, message.title), createdAt: message.createdAt ?? new Date().toISOString(), status, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined }; } -function normalizeChatMessageStatus(value: unknown): ChatMessage["status"] { - const explicit = normalizedStatusText(value); - const explicitTerminal = terminalChatStatus(explicit); - if (explicitTerminal) return explicitTerminal; - if (explicit === "sent") return "sent"; - if (explicit === "blocked") return "blocked"; - if (explicit === "timeout") return "timeout"; - if (explicit === "failed" || explicit === "error") return "failed"; - if (explicit === "canceled" || explicit === "cancelled") return "canceled"; - return "source"; -} - -function terminalChatStatus(value: string | null): ChatMessage["status"] | null { - if (value === "completed") return "completed"; - if (value === "blocked") return "blocked"; - if (value === "timeout") return "timeout"; - if (value === "canceled" || value === "cancelled") return "canceled"; - if (["failed", "error", "stale", "thread-resume-failed"].includes(value ?? "")) return "failed"; - return null; -} - function activeTraceIdFromMessages(messages: ChatMessage[], turnStatusAuthority: Record): string | null { for (const message of [...messages].reverse()) { if (message.role !== "agent") continue; @@ -774,6 +781,16 @@ function messageNeedsTerminalDiagnostics(message: ChatMessage): boolean { return !agentRun || (message.status !== "completed" && !error); } +function messageNeedsTraceHydration(message: ChatMessage): boolean { + if (message.role !== "agent") return false; + const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId); + if (!traceId) return false; + const trace = message.runnerTrace; + const events = Array.isArray(trace?.events) ? trace.events : []; + if (trace?.fullTraceLoaded === true && trace.eventsCompacted !== true) return false; + return events.length === 0 || trace?.eventsCompacted === true || trace?.fullTraceLoaded !== true; +} + function agentRunFromResult(result: AgentChatResultResponse, runnerTrace: ChatMessage["runnerTrace"]): AgentRunProvenance | null { return asAgentRun(result.agentRun ?? runnerTrace?.agentRun); } @@ -870,7 +887,7 @@ function statusFromResult(status: string | undefined): ChatMessage["status"] { if (status === "timeout") return "timeout"; if (status === "canceled" || status === "cancelled") return "canceled"; if (status === "completed") return "completed"; - if (!status || status === "unknown") return "source"; + if (!status || status === "unknown") return "pending"; return "failed"; } diff --git a/web/hwlab-cloud-web/src/style.css b/web/hwlab-cloud-web/src/style.css index 1d73a53f..a5a0ccef 100644 --- a/web/hwlab-cloud-web/src/style.css +++ b/web/hwlab-cloud-web/src/style.css @@ -138,12 +138,6 @@ color: #991b1b; } - .status-badge[data-tone="source"] { - border-color: #bfdbfe; - background: #eff6ff; - color: #1d4ed8; - } - .page-header { display: grid; gap: 6px; diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index b308af13..7b689176 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -730,6 +730,7 @@ width: 100%; } +.message-card[data-role="agent"][data-status="pending"], .message-card[data-role="agent"][data-status="running"] { border-color: #7dd3fc; background: @@ -792,27 +793,19 @@ .agent-running-indicator { display: inline-flex; - width: 28px; - height: 18px; + width: 22px; + height: 22px; align-items: center; justify-content: center; - gap: 3px; } .agent-running-indicator span { - width: 5px; - height: 5px; + width: 14px; + height: 14px; border-radius: 999px; - background: #0891b2; - animation: agent-run-dot 900ms ease-in-out infinite; -} - -.agent-running-indicator span:nth-child(2) { - animation-delay: 120ms; -} - -.agent-running-indicator span:nth-child(3) { - animation-delay: 240ms; + border: 2px solid #bae6fd; + border-top-color: #0891b2; + animation: workbench-spin 0.8s linear infinite; } .message-text, @@ -823,6 +816,12 @@ line-height: 1.55; } +.message-loading { + align-self: flex-start; + border-radius: 3px; + padding: 6px 0; +} + .message-markdown { display: grid; gap: 8px; @@ -1339,11 +1338,6 @@ 55%, 100% { background-position: calc(100% + 180px) 0, 0 0; } } -@keyframes agent-run-dot { - 0%, 80%, 100% { opacity: 0.35; transform: translateY(0); } - 40% { opacity: 1; transform: translateY(-3px); } -} - .command-composer { display: grid; gap: 10px; diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 8d1e99aa..4aaf57f7 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -1,8 +1,8 @@ -export type Tone = "ok" | "source" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run"; +export type Tone = "ok" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run"; export type AuthState = "checking" | "login" | "authenticated"; export type ProviderProfile = string; export type ChatRole = "user" | "agent" | "system"; -export type ChatStatus = "source" | "sent" | "running" | "completed" | "failed" | "blocked" | "timeout" | "canceled"; +export type ChatStatus = "pending" | "sent" | "running" | "completed" | "failed" | "blocked" | "timeout" | "canceled"; export type AccessUserRole = "admin" | "member" | "viewer" | string; export type AccessUserStatus = "active" | "disabled" | "pending" | string; export type AccessToolId = "code-agent" | "hwpod" | "provider-profiles" | "access-admin" | string; @@ -168,7 +168,6 @@ export interface ChatMessage { sessionId?: string | null; threadId?: string | null; retryInput?: string | null; - traceReplayStatus?: string | null; runnerTrace?: RunnerTrace | null; traceEvents?: TraceEvent[]; error?: { code?: string; message?: string; category?: string; providerStatus?: number; [key: string]: unknown } | null; @@ -282,7 +281,7 @@ export interface WebPerformanceRow extends Record { p50?: number; p95?: number; unit?: "seconds" | "score" | string; - tone?: "ok" | "warn" | "blocked" | "source" | string; + tone?: "ok" | "warn" | "blocked" | "pending" | string; problem?: string; } diff --git a/web/hwlab-cloud-web/src/utils/index.ts b/web/hwlab-cloud-web/src/utils/index.ts index dd37cc6a..20e2d6aa 100644 --- a/web/hwlab-cloud-web/src/utils/index.ts +++ b/web/hwlab-cloud-web/src/utils/index.ts @@ -72,7 +72,7 @@ export function statusTone(status: string | undefined | null): string { if (["warn", "warning", "degraded", "build_time_unavailable"].includes(text)) return "warn"; if (["blocked", "stale"].includes(text)) return "blocked"; if (["failed", "error", "timeout", "canceled", "cancelled"].includes(text)) return "error"; - return "source"; + return "pending"; } export function formatRelativeTime(value: string | null | undefined): string { diff --git a/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue b/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue index b91c369e..4c6c479e 100644 --- a/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue +++ b/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue @@ -11,7 +11,7 @@ onMounted(() => void hwpod.refresh());