diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index a74dbbee..aa9bb8f7 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -104,6 +104,7 @@ export async function handleCodeAgentChatHttp(request, response, options) { traceId }); const traceUrl = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`; + const turnUrl = `/v1/agent/turns/${encodeURIComponent(traceId)}`; sendJson(response, 202, { accepted: true, status: "running", @@ -116,6 +117,7 @@ export async function handleCodeAgentChatHttp(request, response, options) { sessionId: safeSessionId(nativeSessionChatParams.sessionId) || null, traceUrl, resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`, + turnUrl, streamUrl: `${traceUrl}/stream`, cancelUrl: "/v1/agent/chat/cancel", polling: { @@ -1156,6 +1158,180 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti }); } +export async function handleCodeAgentTurnHttp(request, response, url, options) { + const parts = url.pathname.split("/").filter(Boolean); + const traceId = decodeURIComponent(parts[3] ?? ""); + if (!safeTraceId(traceId)) { + sendJson(response, 400, { + ok: false, + status: "unknown", + running: false, + terminal: false, + error: { + code: "invalid_trace_id", + message: "traceId must start with trc_ and contain only safe identifier characters" + } + }); + return; + } + const resolved = await resolveCodeAgentTurnStatusSnapshot(traceId, options); + sendJson(response, resolved.statusCode, resolved.body); +} + +async function resolveCodeAgentTurnStatusSnapshot(traceId, options) { + const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; + let result = options.codeAgentChatResults?.get(traceId) ?? null; + if (result && !canAccessOwnedResult(result, options.actor)) return forbiddenTurnSnapshot(traceId); + + const adapterEnabled = codeAgentAgentRunAdapterEnabled(options.env ?? process.env); + let resultPollError = null; + if (adapterEnabled && (result?.agentRun?.runId || !result)) { + try { + const synced = await syncAgentRunChatResult({ traceId, currentResult: result, options, traceStore }); + result = synced.result ?? result; + if (result && !canAccessOwnedResult(result, options.actor)) return forbiddenTurnSnapshot(traceId); + if (result && isTraceCommandTerminalStatus(result.status)) { + await finalizeCodeAgentBillingUsage({ payload: result, params: result, options }); + recordCodeAgentConversationFact(result, options); + await recordCodeAgentSessionOwner({ payload: result, params: result, options, status: codeAgentOwnerStatusForResult(result) }); + } + } catch (error) { + resultPollError = error; + traceStore.append(traceId, { + type: "turn-status", + status: "degraded", + label: "turn-status:result-sync-failed", + errorCode: error?.code ?? "agentrun_result_poll_failed", + message: error?.message ?? "AgentRun result polling failed", + waitingFor: "agentrun-result", + valuesPrinted: false + }); + } + } + + let agentRunResult = result?.agentRun ? result : null; + if (!agentRunResult && adapterEnabled) { + try { + agentRunResult = await loadPersistedAgentRunResult(traceId, options); + } catch { + agentRunResult = null; + } + } + if (agentRunResult && !canAccessOwnedResult(agentRunResult, options.actor)) return forbiddenTurnSnapshot(traceId); + + let refreshError = null; + if (agentRunResult?.agentRun) { + try { + const refreshedTrace = await refreshAgentRunTrace({ traceId, result: agentRunResult, options, traceStore }); + agentRunResult = options.codeAgentChatResults?.get?.(traceId) ?? agentRunResult; + if (traceNeedsCommandResultSync(agentRunResult, refreshedTrace)) { + const synced = await syncAgentRunChatResult({ traceId, currentResult: agentRunResult, options, traceStore, appendResultEvent: false, refreshEvents: false }); + agentRunResult = synced.result ?? agentRunResult; + } + if (isTraceCommandTerminalStatus(agentRunResult?.status)) { + await finalizeCodeAgentBillingUsage({ payload: agentRunResult, params: agentRunResult, options }); + recordCodeAgentConversationFact(agentRunResult, options); + await recordCodeAgentSessionOwner({ payload: agentRunResult, params: agentRunResult, options, status: codeAgentOwnerStatusForResult(agentRunResult), preserveLastTraceId: true }); + } + } catch (error) { + refreshError = error; + } + } + + const snapshot = traceSnapshotWithTerminalEvidence(traceStore.snapshot(traceId), agentRunResult ?? result, traceId, refreshError); + const body = codeAgentTurnStatusPayload({ traceId, result: agentRunResult ?? result, snapshot, resultPollError, refreshError, options }); + return { statusCode: body.ok ? 200 : 404, body }; +} + +function forbiddenTurnSnapshot(traceId) { + return { + statusCode: 403, + body: { + ok: false, + status: "unknown", + running: false, + terminal: false, + traceId, + error: { + code: "agent_session_owner_required", + message: "Only the session owner or admin can read this Code Agent turn status" + } + } + }; +} + +function codeAgentTurnStatusPayload({ traceId, result, snapshot, resultPollError, refreshError, options }) { + const resultObject = result && typeof result === "object" ? result : null; + const snapshotObject = snapshot && typeof snapshot === "object" ? snapshot : null; + const events = Array.isArray(snapshotObject?.events) ? snapshotObject.events : Array.isArray(resultObject?.runnerTrace?.events) ? resultObject.runnerTrace.events : []; + const lastEvent = events.at(-1) ?? null; + const status = normalizeTurnStatus( + resultObject?.status, + resultObject?.agentRun?.terminalStatus, + resultObject?.agentRun?.commandState, + resultObject?.agentRun?.status, + snapshotObject?.terminalEvidence?.traceSummary?.terminalStatus, + snapshotObject?.traceStatus, + snapshotObject?.status, + snapshotObject?.runnerTrace?.status + ); + const found = Boolean(resultObject || (snapshotObject && snapshotObject.status !== "missing") || snapshotObject?.persisted === true); + const running = isTurnRunningStatus(status); + const terminal = isTurnTerminalStatus(status); + const runnerTrace = snapshotObject && snapshotObject.status !== "missing" ? snapshotObject : resultObject?.runnerTrace ?? null; + return { + ok: found, + action: "code-agent.turn.status", + contractVersion: "code-agent-turn-status-v1", + status: found ? status ?? "unknown" : "unknown", + running: found ? running : false, + terminal: found ? terminal : false, + traceId, + conversationId: safeConversationId(resultObject?.conversationId ?? snapshotObject?.conversationId) || null, + sessionId: safeSessionId(resultObject?.sessionId ?? resultObject?.session?.sessionId ?? snapshotObject?.sessionId) || null, + threadId: safeOpaqueId(resultObject?.threadId ?? resultObject?.session?.threadId ?? snapshotObject?.threadId) || null, + updatedAt: textValue(resultObject?.updatedAt ?? resultObject?.agentRun?.updatedAt ?? snapshotObject?.updatedAt) || null, + 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)}`, + turnUrl: `/v1/agent/turns/${encodeURIComponent(traceId)}`, + runnerTrace: runnerTrace ? compactRunnerTraceForResult(runnerTrace, resultTraceEventLimit(options)) : null, + agentRun: resultObject?.agentRun ?? snapshotObject?.agentRun ?? null, + terminalEvidence: snapshotObject?.terminalEvidence ?? null, + finalResponse: resultObject?.finalResponse ?? snapshotObject?.finalResponse ?? snapshotObject?.terminalEvidence?.finalResponse ?? null, + traceSummary: resultObject?.traceSummary ?? snapshotObject?.traceSummary ?? snapshotObject?.terminalEvidence?.traceSummary ?? null, + retention: snapshotObject?.retention ?? null, + eventCount: numberOrNull(snapshotObject?.eventCount ?? runnerTrace?.eventCount ?? events.length), + error: resultPollError || refreshError + ? { code: resultPollError?.code ?? refreshError?.code ?? "turn_status_degraded", message: resultPollError?.message ?? refreshError?.message ?? "Code Agent turn status refresh degraded", valuesPrinted: false } + : resultObject?.error ?? snapshotObject?.error ?? null, + valuesRedacted: true, + secretMaterialStored: false + }; +} + +function normalizeTurnStatus(...values) { + for (const value of values) { + const text = textValue(value).toLowerCase().replace(/_/gu, "-"); + if (!text) continue; + if (["accepted", "pending", "processing", "running", "busy", "creating", "queued", "in-flight"].includes(text)) return "running"; + if (["completed", "done", "succeeded", "success"].includes(text)) return "completed"; + if (["failed", "failure", "error", "stale", "thread-resume-failed", "aborted", "interrupted", "expired"].includes(text)) return "failed"; + if (["cancelled", "canceled"].includes(text)) return "canceled"; + if (["blocked", "timeout"].includes(text)) return text; + } + return null; +} + +function isTurnRunningStatus(status) { + return status === "running"; +} + +function isTurnTerminalStatus(status) { + return CODE_AGENT_TERMINAL_STATUSES.has(String(status ?? "").trim().toLowerCase().replace(/_/gu, "-")); +} + export async function handleCodeAgentInspectHttp(request, response, url, options) { const query = { conversationId: safeConversationId(url.searchParams.get("conversationId")), diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 1393184e..341e8c15 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -59,6 +59,7 @@ import { handleCodeAgentInspectHttp, handleCodeAgentSessionsHttp, handleCodeAgentSteerHttp, + handleCodeAgentTurnHttp, handleCodeAgentTraceHttp } from "./server-code-agent-http.ts"; import { handleM3IoControlHttp } from "./server-m3-http.ts"; @@ -627,6 +628,13 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (request.method === "GET" && url.pathname.startsWith("/v1/agent/turns/")) { + const nextOptions = await codeAgentOptions(request, response, options); + if (!nextOptions) return; + await handleCodeAgentTurnHttp(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; diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 49f8a067..dc76cc36 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -133,6 +133,7 @@ function isAgentConversationWriteProxyRoute(method, pathname) { function isPublicCodeAgentPollRoute(method, pathname) { return method === "GET" && ( + pathname.startsWith("/v1/agent/turns/") || pathname.startsWith("/v1/agent/chat/result/") || pathname.startsWith("/v1/agent/chat/trace/") ); diff --git a/internal/dev-entrypoint/http.test.mjs b/internal/dev-entrypoint/http.test.mjs index d00e7e85..76996577 100644 --- a/internal/dev-entrypoint/http.test.mjs +++ b/internal/dev-entrypoint/http.test.mjs @@ -49,6 +49,12 @@ test("cloud web route policy proxies public Code Agent chat without gating other publicRoute: true, routeKey: "GET /v1/agent/chat/result/trc_live" }); + assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/turns/trc_live"), { + proxy: true, + authRequired: false, + publicRoute: true, + routeKey: "GET /v1/agent/turns/trc_live" + }); assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1"), { proxy: true, authRequired: true, diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index ab92569f..c2039a18 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -997,7 +997,7 @@ async function agentCommand(context: any) { if (subcommand === "composer") return agentComposer(context); if (subcommand === "result") { const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId); - const pathName = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`; + const pathName = `/v1/agent/turns/${encodeURIComponent(traceId)}`; const response = await requestJson({ ...context, method: "GET", path: pathName }); return responsePayload("client.agent.result", response, context, { route: route("GET", pathName), @@ -1009,13 +1009,17 @@ 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 turnPath = `/v1/agent/turns/${encodeURIComponent(traceId)}`; + const turnResponse = await requestJson({ ...context, method: "GET", path: turnPath }); const response = await requestJson({ ...context, method: "GET", path: pathName }); const traceBody = traceBodyForCli(response.body, context.parsed); return responsePayload("client.agent.trace", response, context, { route: route("GET", pathName), + turnRoute: route("GET", turnPath), traceId, body: traceBody, - traceResultSummary: traceResultSummaryForCli(response.body, { traceId, command: "trace", renderedTrace: traceBody }), + turnStatus: responseBodyForCli(turnResponse.body, context.parsed), + traceResultSummary: traceResultSummaryForCli(turnResponse.body ?? response.body, { traceId, command: "trace", renderedTrace: traceBody }), ...traceResponseAliases(traceBody, context.parsed) }); } @@ -1526,7 +1530,7 @@ async function sessionFinalResponseCommand(context: any) { }; } const traceId = requiredTraceId(resolvedTraceId); - const resultPath = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`; + const resultPath = `/v1/agent/turns/${encodeURIComponent(traceId)}`; const resultResponse = await requestJson({ ...context, method: "GET", path: resultPath }); const validation = finalResponseValidation({ conversationId, @@ -2318,14 +2322,14 @@ async function pollAgentResult(context: any, traceId: string, acceptedBody: any) const timeoutMs = numberOption(context.parsed.timeoutMs) ?? DEFAULT_AGENT_TIMEOUT_MS; const pollIntervalMs = numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS; const startedAt = Date.now(); - const resultPath = text(acceptedBody?.resultUrl) || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`; + const resultPath = text(acceptedBody?.turnUrl) || `/v1/agent/turns/${encodeURIComponent(traceId)}`; let polls = 0; let lastResponse = null; while (Date.now() - startedAt < timeoutMs) { polls += 1; const response = await requestJson({ ...context, method: "GET", path: resultPath, timeoutMs: Math.min(DEFAULT_TIMEOUT_MS, pollIntervalMs + 2000) }); lastResponse = response; - if (response.status === 200 && response.body?.status && response.body.status !== "running") { + if (response.status === 200 && response.body?.terminal === true) { return { final: true, response, polls, timeoutMs, resultPath }; } await context.sleep(pollIntervalMs); @@ -2337,7 +2341,7 @@ function agentSendWaitPolicy(traceId: string) { return { defaultWait: false, webEquivalent: true, - reason: "Cloud Web submits /v1/agent/chat as a short request and observes progress by polling result/trace; CLI follows the same path by default to avoid UniDesk ssh/tran 60s runtime disconnects.", + reason: "Cloud Web submits /v1/agent/chat as a short request and observes progress by polling /v1/agent/turns/:traceId; trace is only used for event details.", waitCommand: `hwlab-cli client agent send --from-trace ${traceId} --message TEXT --wait`, nextCommands: [ `hwlab-cli client agent result ${traceId}`, diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index f7273348..6d8ec612 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -95,7 +95,7 @@ assertIncludes(appSource, "useForm", "form composable must remain available for assertIncludes(appSource, "hwlab_session", "auth comments/code must preserve Web session cookie boundary"); assertIncludes(appSource, "activityRef", "Code Agent inactivity-timeout activityRef must be preserved"); assertIncludes(appSource, "activityRef: () => activityRef.value", "Workbench must pass activityRef into trace subscription"); -assertIncludes(appSource, "getAgentChatResult(resultUrl, inactivityTimeoutMs, activityRef)", "Trace result polling must use activityRef-backed inactivity timeout"); +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/workbench/workspace", "workspace bootstrap must use Cloud Web same-origin workbench API"); 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 e0fcef45..0e0bff0d 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, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, resolveConversationSessionStatus, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts"; +import { defaultProviderProfileOptions, 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" }); @@ -15,7 +15,7 @@ test("R2 composer ignores stale workspace activeTraceId without verified active test("R2 composer steers only when an active agent message or status exists", () => { const messages: ChatMessage[] = [agentMessage({ status: "running", traceId: "trc_running", conversationId: "cnv_r2", sessionId: "ses_r2" })]; - const composer = resolveComposerState({ workspace: workspaceRecord({ activeTraceId: "trc_running", sessionStatus: "running" }), messages, activeConversationId: "cnv_r2", chatPending: true }); + const composer = resolveComposerState({ workspace: workspaceRecord({ activeTraceId: "trc_running", sessionStatus: "running" }), messages, activeConversationId: "cnv_r2", chatPending: true, turnStatusAuthority: { trc_running: { traceId: "trc_running", status: "running", running: true, terminal: false, sessionId: "ses_r2" } } }); assert.equal(composer.submitMode, "steer"); assert.equal(composer.route, "/v1/agent/chat/steer"); assert.equal(composer.targetTraceId, "trc_running"); @@ -31,14 +31,16 @@ test("R2 composer cancel targets only the active conversation trace", () => { activeConversationId: "cnv_active", targetTraceId: "trc_active", targetSessionId: "ses_active", - targetThreadId: "thr_active" + targetThreadId: "thr_active", + turnStatusAuthority: { trc_active: { traceId: "trc_active", status: "running", running: true, terminal: false, sessionId: "ses_active", threadId: "thr_active" } } })?.traceId, "trc_active"); assert.equal(resolveCancelableAgentMessage({ messages, activeConversationId: "cnv_active", targetTraceId: "trc_other", targetSessionId: "ses_active", - targetThreadId: "thr_active" + targetThreadId: "thr_active", + turnStatusAuthority: { trc_other: { traceId: "trc_other", status: "running", running: true, terminal: false, sessionId: "ses_other", threadId: "thr_other" } } }), null); }); @@ -98,6 +100,13 @@ test("R2 session tabs render only conversations supplied by the list API", () => assert.equal(tabs.length, 0); }); +test("R2 session list keeps current tabs when include response misses selected conversation", () => { + const current: ConversationRecord[] = [{ conversationId: "cnv_selected", sessionId: "ses_selected", firstUserMessagePreview: "当前会话" }]; + assert.deepEqual(stableConversationList(current, [], "cnv_selected"), current); + assert.equal(stableConversationList(current, [{ conversationId: "cnv_selected", sessionId: "ses_selected" }], "cnv_selected").length, 1); + assert.deepEqual(stableConversationList(current, [], null), []); +}); + test("R2 drafts keep recent unique values and normalize corrupt storage", () => { const next = recordRecentDraft([{ text: "旧输入", ts: "2026-01-01T00:00:00.000Z" }], "新输入", "2026-01-02T00:00:00.000Z"); assert.deepEqual(next.map((item) => item.text), ["新输入", "旧输入"]); @@ -186,7 +195,6 @@ test("R2 session tabs keep trace metadata but status comes only from session aut snapshot: { sessionStatus: "running" }, messages: [agentMessage({ conversationId: "cnv_stale_snapshot", status: "running", traceId: "trc_stale" })] }; - assert.equal(resolveConversationSessionStatus(conversation), "running"); const tabs = sortSessionTabs([conversation], "cnv_stale_snapshot"); assert.equal(tabs[0]?.status, "unknown"); assert.equal(tabs[0]?.running, false); diff --git a/web/hwlab-cloud-web/scripts/workbench-trace-lifecycle.test.ts b/web/hwlab-cloud-web/scripts/workbench-trace-lifecycle.test.ts index ecaa7976..1a88f86e 100644 --- a/web/hwlab-cloud-web/scripts/workbench-trace-lifecycle.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-trace-lifecycle.test.ts @@ -8,7 +8,7 @@ test("Trace lifecycle expands only active agent turns", () => { assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running" } })), true); assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "pending" } })), true); assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", runnerTrace: { status: "running" } })), false); - assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "completed" } })), false); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "completed" } })), true); assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running", finalResponse: "ok" } })), false); assert.equal(traceLifecycleExpanded(agentMessage({ status: "failed", runnerTrace: { status: "failed" } })), false); assert.equal(traceLifecycleExpanded({ ...agentMessage({ status: "running" }), role: "user", title: "用户" }), false); diff --git a/web/hwlab-cloud-web/src/api/agent.ts b/web/hwlab-cloud-web/src/api/agent.ts index c6893678..75ffcba0 100644 --- a/web/hwlab-cloud-web/src/api/agent.ts +++ b/web/hwlab-cloud-web/src/api/agent.ts @@ -5,6 +5,7 @@ export const agentAPI = { sendAgentMessage: (payload: Record, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent", activityRef }), 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 }), 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/components/workbench/CodeAgentStatusSummary.vue b/web/hwlab-cloud-web/src/components/workbench/CodeAgentStatusSummary.vue index ff6fa65e..ff6bfa7b 100644 --- a/web/hwlab-cloud-web/src/components/workbench/CodeAgentStatusSummary.vue +++ b/web/hwlab-cloud-web/src/components/workbench/CodeAgentStatusSummary.vue @@ -16,8 +16,8 @@ const errorTitle = computed(() => firstNonEmptyString(error.value?.code, error.v const errorMessage = computed(() => firstNonEmptyString(error.value?.message, error.value?.userMessage, error.value?.reason)); const rows = computed(() => [ - summaryRow("messageStatus", "消息状态", props.message.status), - summaryRow("traceStatus", "Trace 状态", firstNonEmptyString(trace.value?.status, trace.value?.traceStatus)), + summaryRow("turnStatus", "Turn 状态", props.message.status), + summaryRow("traceDetailStatus", "Trace 明细", firstNonEmptyString(trace.value?.status, trace.value?.traceStatus)), summaryRow("traceId", "Trace", props.message.traceId ?? trace.value?.traceId), summaryRow("conversationId", "Conversation", props.message.conversationId), summaryRow("sessionId", "Session", props.message.sessionId ?? trace.value?.sessionId), diff --git a/web/hwlab-cloud-web/src/components/workbench/trace-lifecycle.ts b/web/hwlab-cloud-web/src/components/workbench/trace-lifecycle.ts index b0df7218..b1c6a7f8 100644 --- a/web/hwlab-cloud-web/src/components/workbench/trace-lifecycle.ts +++ b/web/hwlab-cloud-web/src/components/workbench/trace-lifecycle.ts @@ -3,7 +3,7 @@ import type { ChatMessage } from "@/types"; export function traceLifecycleExpanded(message: ChatMessage): boolean { if (message.role !== "agent") return false; if (hasTraceTerminalSignal(message)) return false; - return traceStatusValues(message).some(isTraceActiveStatus); + return isTraceActiveStatus(message.status); } function hasTraceTerminalSignal(message: ChatMessage): boolean { @@ -14,8 +14,6 @@ function hasTraceTerminalSignal(message: ChatMessage): boolean { function traceStatusValues(message: ChatMessage): string[] { return [ message.status, - message.runnerTrace?.status, - message.runnerTrace?.traceStatus ].map(normalizeTraceStatus).filter((value): value is string => Boolean(value)); } diff --git a/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts b/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts index 43075a1c..753981ba 100644 --- a/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts +++ b/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts @@ -152,48 +152,30 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise const { traceId, initial, onActivity, onSnapshot, onComplete, onInfrastructureError, signal, inactivityTimeoutMs, activityRef } = config; const projectId = normalizeWorkbenchProjectId(config.projectId) ?? DEFAULT_WORKBENCH_PROJECT_ID; if (isTerminalStatus(initial.status)) { - const traceSnapshot = await fetchTraceSnapshot(traceId, traceId, inactivityTimeoutMs, signal, projectId, activityRef); - if (signal.aborted) return; - if (traceSnapshot) onSnapshot(traceSnapshot); - onComplete(mergeTraceResults(initial as AgentChatResultResponse, traceSnapshot)); + const snapshot = resultToTraceSnapshot(traceId, initial as AgentChatResultResponse); + onSnapshot(snapshot); + onComplete(mergeTraceResults(initial as AgentChatResultResponse, snapshot)); return; } - if (!initial.resultUrl) { - onInfrastructureError("Code Agent initial response is missing resultUrl"); + if (!initial.turnUrl && !traceId) { + onInfrastructureError("Code Agent initial response is missing traceId for turn status polling"); return; } - const resultUrl = initial.resultUrl; - const traceUrl = traceUrlFromResultUrl(resultUrl, traceId, projectId); let lastEventCount = -1; let lastStatus: string | null = null; - let terminalResult: AgentChatResultResponse | null = null; for (;;) { if (signal.aborted) return; await sleep(TRACE_POLL_INTERVAL_MS); if (signal.aborted) return; - const [resultPolled, tracePolled] = await Promise.all([ - agentAPI.getAgentChatResult(resultUrl, inactivityTimeoutMs, activityRef), - agentAPI.getAgentChatResult(traceUrl, inactivityTimeoutMs, activityRef) - ]); + const turnPolled = await agentAPI.getAgentTurn(traceId, projectId, inactivityTimeoutMs, activityRef); if (signal.aborted) return; - if (resultPolled.ok && resultPolled.data) { + if (turnPolled.ok && turnPolled.data) { onActivity(); - if (isTerminalStatus(resultPolled.data.status)) terminalResult = resultPolled.data; - } else if (!resultPolled.ok && resultPolled.status >= 500) { - // Backend hiccup: keep polling and let inactivity-timeout classify a real outage. - } else if (!resultPolled.ok) { - onInfrastructureError(resultPolled.error ?? "Code Agent result poll failed (non-5xx)"); - return; - } - - let snapshot: TraceSnapshot | null = null; - if (tracePolled.ok && tracePolled.data) { - onActivity(); - snapshot = resultToTraceSnapshot(traceId, tracePolled.data); + const snapshot = resultToTraceSnapshot(traceId, turnPolled.data); const eventCount = snapshot.eventCount ?? snapshot.events?.length ?? 0; const status = snapshot.status ?? ""; if (eventCount !== lastEventCount || status !== lastStatus) { @@ -201,28 +183,19 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise lastStatus = status; onSnapshot(snapshot); } - } else if (!tracePolled.ok && tracePolled.status < 500) { - onInfrastructureError(tracePolled.error ?? "Code Agent trace poll failed (non-5xx)"); - return; - } - - if (terminalResult) { - const finalTrace = snapshot ?? await fetchTraceSnapshot(traceId, traceUrl, inactivityTimeoutMs, signal, projectId, activityRef); - if (signal.aborted) return; - if (finalTrace) onSnapshot(finalTrace); - onComplete(mergeTraceResults(terminalResult, finalTrace)); + if (turnPolled.data.terminal === true || isTerminalStatus(turnPolled.data.status)) { + onComplete(mergeTraceResults(turnPolled.data, snapshot)); + return; + } + } else if (!turnPolled.ok && turnPolled.status >= 500) { + // Backend hiccup: keep polling and let inactivity-timeout classify a real outage. + } else { + onInfrastructureError(turnPolled.error ?? "Code Agent turn status poll failed (non-5xx)"); return; } } } -async function fetchTraceSnapshot(traceId: string, traceUrlOrResultUrl: string, timeoutMs: number, signal: AbortSignal, projectId: string, activityRef: ActivityRefSource): Promise { - const traceUrl = traceUrlOrResultUrl.includes("/trace/") ? traceUrlOrResultUrl : traceUrlFromResultUrl(traceUrlOrResultUrl, traceId, projectId); - const response = await agentAPI.getAgentChatResult(traceUrl, timeoutMs, activityRef); - if (signal.aborted || !response.ok || !response.data) return null; - return resultToTraceSnapshot(traceId, response.data); -} - function resultToTraceSnapshot(traceId: string, result: AgentChatResultResponse): TraceSnapshot { const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : []; const lastEvent = events.at(-1); @@ -247,12 +220,6 @@ function resultToTraceSnapshot(traceId: string, result: AgentChatResultResponse) }; } -function traceUrlFromResultUrl(resultUrl: string, traceId: string, projectId: string): string { - const url = resultUrl.replace(/\/v1\/agent\/chat\/result\/[^/?#]+/u, `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`); - if (/[?&]projectId=/u.test(url)) return url; - return `${url}${url.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}`; -} - function sleep(ms: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, ms)); } diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 343f5ae7..9376edad 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -30,6 +30,20 @@ export interface SessionStatusAuthority { export type SessionStatusAuthorityMap = Readonly>; +export interface TurnStatusAuthority { + traceId: string; + status: string | null; + running: boolean; + terminal: boolean; + conversationId?: string | null; + sessionId?: string | null; + threadId?: string | null; + updatedAt?: string | null; + loadedAt?: string | null; +} + +export type TurnStatusAuthorityMap = Readonly>; + export interface DraftEntry { text: string; ts: string; @@ -54,35 +68,36 @@ const BUILTIN_PROVIDER_PROFILE_LABELS: Readonly> = Object "minimax-m3": "MiniMax-M3" }); -export function resolveComposerState(input: { workspace: WorkspaceRecord | null; messages: ChatMessage[]; conversations?: ConversationRecord[]; activeConversationId: string | null; chatPending: boolean; currentRequest?: { traceId?: string | null; conversationId?: string | null; sessionId?: string | null; threadId?: string | null; status?: string | null } | null }): ComposerState { +export function resolveComposerState(input: { workspace: WorkspaceRecord | null; messages: ChatMessage[]; conversations?: ConversationRecord[]; activeConversationId: string | null; chatPending: boolean; currentRequest?: { traceId?: string | null; conversationId?: string | null; sessionId?: string | null; threadId?: string | null; status?: string | null } | null; turnStatusAuthority?: TurnStatusAuthorityMap }): ComposerState { const latestMessage = latestConversationMessage(input.messages, input.activeConversationId); - const runningMessage = findActiveAgentMessage(input.messages, input.activeConversationId); const currentRequest = input.currentRequest && input.currentRequest.conversationId === input.activeConversationId ? input.currentRequest : null; - const workspaceStatus = firstNonEmptyString(input.workspace?.workspace?.sessionStatus); const workspaceTraceId = firstNonEmptyString(input.workspace?.activeTraceId, input.workspace?.workspace?.activeTraceId); - const activeTraceId = firstNonEmptyString(currentRequest?.traceId, runningMessage?.traceId, runningMessage?.runnerTrace?.traceId, runningMessage ? workspaceTraceId : null); - const activeByStatus = isActiveStatus(runningMessage?.status) || isActiveStatus(runningMessage?.runnerTrace?.status) || isActiveStatus(currentRequest?.status) || input.chatPending; - const terminal = isTerminalStatus(latestMessage?.status) || isTerminalStatus(currentRequest?.status) || (!latestMessage && isTerminalStatus(workspaceStatus)); + const latestTraceId = firstNonEmptyString(latestMessage?.traceId, latestMessage?.runnerTrace?.traceId); + const activeTraceId = firstNonEmptyString(currentRequest?.traceId, workspaceTraceId, latestTraceId); + const turn = activeTraceId ? input.turnStatusAuthority?.[activeTraceId] : null; + const activeByStatus = turn?.running === true || isActiveStatus(turn?.status); + const terminal = turn?.terminal === true || isTerminalStatus(turn?.status); const conversationId = input.activeConversationId; const active = activeConversation(input.conversations ?? [], conversationId); const workspaceConversationId = selectedConversationIdFromWorkspace(input.workspace); const workspaceMatchesConversation = !conversationId || !workspaceConversationId || workspaceConversationId === conversationId; const workspaceSessionId = workspaceMatchesConversation ? firstNonEmptyString(input.workspace?.selectedAgentSessionId, input.workspace?.workspace?.selectedAgentSessionId) : null; const workspaceThreadId = workspaceMatchesConversation ? firstNonEmptyString(input.workspace?.workspace?.threadId) : null; - const sessionId = firstNonEmptyString(runningMessage?.sessionId, currentRequest?.sessionId, active?.sessionId, workspaceSessionId); - const threadId = firstNonEmptyString(runningMessage?.threadId, currentRequest?.threadId, active?.threadId, workspaceThreadId); + const sessionId = firstNonEmptyString(turn?.sessionId, currentRequest?.sessionId, active?.sessionId, workspaceSessionId); + const threadId = firstNonEmptyString(turn?.threadId, currentRequest?.threadId, active?.threadId, workspaceThreadId); const canSteer = Boolean(conversationId && sessionId && activeTraceId && activeByStatus && !terminal); if (canSteer) return { disabled: false, disabledReason: null, submitMode: "steer", route: "/v1/agent/chat/steer", targetTraceId: activeTraceId, conversationId, sessionId, threadId }; if (!sessionId) return { disabled: true, disabledReason: "session_required", submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null, conversationId, sessionId: null, threadId }; return { disabled: false, disabledReason: null, submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null, conversationId, sessionId, threadId }; } -export function resolveCancelableAgentMessage(input: { messages: ChatMessage[]; activeConversationId: string | null; targetTraceId?: string | null; targetSessionId?: string | null; targetThreadId?: string | null }): ChatMessage | null { +export function resolveCancelableAgentMessage(input: { messages: ChatMessage[]; activeConversationId: string | null; targetTraceId?: string | null; targetSessionId?: string | null; targetThreadId?: string | null; turnStatusAuthority?: TurnStatusAuthorityMap }): ChatMessage | null { const targetTraceId = firstNonEmptyString(input.targetTraceId); if (!targetTraceId) return null; + const turn = input.turnStatusAuthority?.[targetTraceId]; + if (turn?.running !== true && !isActiveStatus(turn?.status)) return null; for (const message of [...input.messages].reverse()) { if (message.role !== "agent") continue; - if (!isActiveStatus(message.status) && !isActiveStatus(message.runnerTrace?.status)) continue; if (firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) !== targetTraceId) continue; if (!messageBelongsToCancelTarget(message, input)) continue; return message; @@ -164,48 +179,6 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ }; } -export function resolveConversationSessionStatus(conversation: ConversationRecord, extraStatuses: unknown[] = []): string { - const latestAgent = latestAgentMessage(conversation.messages); - const latestMessage = conversation.messages?.at(-1); - const stored = normalizeSessionStatus(conversation.status); - const terminalEvidence = firstNonActiveStatus( - ...extraStatuses, - conversation.session?.status, - conversation.snapshot?.sessionStatus, - conversation.snapshot?.status, - latestAgent?.status, - latestAgent?.runnerTrace?.status, - latestAgent?.runnerTrace?.traceStatus, - runnerTraceAgentRunStatus(latestAgent?.runnerTrace, "terminalStatus"), - runnerTraceAgentRunStatus(latestAgent?.runnerTrace, "commandState"), - latestMessage?.status - ); - if (stored && isActiveStatus(stored) && terminalEvidence) return terminalEvidence; - const activeEvidence = firstActiveStatus( - latestAgent?.status, - latestAgent?.runnerTrace?.status, - latestAgent?.runnerTrace?.traceStatus, - latestMessage?.status, - ...extraStatuses, - conversation.session?.status, - conversation.snapshot?.sessionStatus, - conversation.snapshot?.status - ); - if (activeEvidence && !isTerminalStatus(stored)) return activeEvidence; - return stored ?? firstNormalizedSessionStatus( - ...extraStatuses, - conversation.session?.status, - conversation.snapshot?.sessionStatus, - conversation.snapshot?.status, - latestAgent?.status, - latestAgent?.runnerTrace?.status, - latestAgent?.runnerTrace?.traceStatus, - runnerTraceAgentRunStatus(latestAgent?.runnerTrace, "terminalStatus"), - runnerTraceAgentRunStatus(latestAgent?.runnerTrace, "commandState"), - latestMessage?.status - ) ?? "source"; -} - export function normalizeWorkbenchMessageTitle(role: ChatMessage["role"], title: unknown): string { const fallback = role === "user" ? "用户" : "Code Agent"; const value = firstNonEmptyString(title) ?? fallback; @@ -218,6 +191,14 @@ export function sortSessionTabs(conversations: ConversationRecord[], activeConve return conversations.map((conversation) => conversationToSessionTab(conversation, activeConversationId, sessionStatusAuthority)).sort((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt)); } +export function stableConversationList(current: ConversationRecord[], candidate: unknown, includeConversationId: string | null | undefined): ConversationRecord[] { + if (!Array.isArray(candidate)) return current; + const includeId = firstNonEmptyString(includeConversationId); + if (!includeId) return candidate as ConversationRecord[]; + if ((candidate as ConversationRecord[]).some((conversation) => conversation.conversationId === includeId)) return candidate as ConversationRecord[]; + return current.length > 0 ? current : candidate as ConversationRecord[]; +} + export function recordRecentDraft(existing: DraftEntry[], text: string, now = new Date().toISOString()): DraftEntry[] { const trimmed = text.trim(); if (!trimmed) return existing; @@ -252,16 +233,6 @@ function latestAgentMessage(messages: ChatMessage[] | undefined): ChatMessage | return [...(messages ?? [])].reverse().find((message) => message.role === "agent") ?? null; } -function findActiveAgentMessage(messages: ChatMessage[], activeConversationId: string | null): ChatMessage | null { - for (const message of [...messages].reverse()) { - if (message.role !== "agent") continue; - if (!isActiveStatus(message.status) && !isActiveStatus(message.runnerTrace?.status)) continue; - if (activeConversationId && message.conversationId && message.conversationId !== activeConversationId) continue; - return message; - } - return null; -} - function messageBelongsToCancelTarget(message: ChatMessage, input: { activeConversationId: string | null; targetSessionId?: string | null; targetThreadId?: string | null }): boolean { const activeConversationId = firstNonEmptyString(input.activeConversationId); const messageConversationId = firstNonEmptyString(message.conversationId); @@ -293,30 +264,6 @@ function isTerminalStatus(value: unknown): boolean { return ["completed", "failed", "blocked", "timeout", "canceled", "stale", "thread-resume-failed", "interrupted", "expired"].includes(normalizeSessionStatus(value) ?? ""); } -function firstNonActiveStatus(...values: unknown[]): string | null { - for (const value of values) { - const status = normalizeSessionStatus(value); - if (status && !isActiveStatus(status) && isTerminalStatus(status)) return status; - } - return null; -} - -function firstActiveStatus(...values: unknown[]): string | null { - for (const value of values) { - const status = normalizeSessionStatus(value); - if (status && isActiveStatus(status)) return status; - } - return null; -} - -function firstNormalizedSessionStatus(...values: unknown[]): string | null { - for (const value of values) { - const status = normalizeSessionStatus(value); - if (status) return status; - } - return null; -} - function normalizeSessionStatus(value: unknown): string | null { const text = firstNonEmptyString(value); if (!text) return null; @@ -327,11 +274,6 @@ function normalizeSessionStatus(value: unknown): string | null { return normalized; } -function runnerTraceAgentRunStatus(runnerTrace: ChatMessage["runnerTrace"], key: string): unknown { - const agentRun = runnerTrace && typeof runnerTrace === "object" ? (runnerTrace as Record).agentRun : null; - return agentRun && typeof agentRun === "object" ? (agentRun as Record)[key] : null; -} - function profileOptionFromUnknown(item: unknown): ProviderProfileOption | null { if (!item || typeof item !== "object") return null; const record = item as Record; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 332fa8af..541f007f 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, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority } from "./workbench-session"; +import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, 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; @@ -30,6 +30,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const currentRequest = ref<{ traceId: string; conversationId: string | null; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null); const workspaceSelectionEpoch = ref(0); const sessionStatusAuthority = ref>({}); + const turnStatusAuthority = ref>({}); 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)); @@ -39,7 +40,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const sessionTabs = computed(() => sortSessionTabs(visibleConversations.value, activeConversationId.value, sessionStatusAuthority.value)); const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, conversationsReady: conversationsReady.value })); const activeProjectId = computed(() => workspaceProjectId(workspace.value, projectId.value)); - const composer = computed(() => resolveComposerState({ workspace: workspace.value, messages: messages.value, conversations: visibleConversations.value, activeConversationId: activeConversationId.value, chatPending: chatPending.value, currentRequest: currentRequest.value })); + const composer = computed(() => resolveComposerState({ workspace: workspace.value, messages: messages.value, conversations: visibleConversations.value, activeConversationId: activeConversationId.value, chatPending: chatPending.value, currentRequest: currentRequest.value, turnStatusAuthority: turnStatusAuthority.value })); function recordActivity(label = "user-activity"): void { const now = Date.now(); @@ -60,8 +61,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { const nextWorkspace = workspaceResult.data?.workspace ?? null; const conversationsResult = await api.workbench.conversations(projectId.value, { includeConversationId: selectedConversationIdFromWorkspace(nextWorkspace) }); loading.value = false; - const nextConversations = conversationsResult.ok ? conversationsResult.data?.conversations ?? [] : conversations.value; + let nextConversations = conversations.value; if (conversationsResult.ok) { + nextConversations = stableConversationList(conversations.value, conversationsResult.data?.conversations, selectedConversationIdFromWorkspace(nextWorkspace)); conversations.value = nextConversations; conversationsReady.value = true; void refreshSessionStatusAuthority(nextConversations); @@ -74,6 +76,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value; rememberWorkbenchProjectId(workspaceProjectId(workspace.value, projectId.value)); messages.value = messagesFromWorkspaceSelection(workspace.value, nextConversations); + void hydrateTurnStatusAuthority(messages.value); void hydrateTerminalMessageDiagnostics(); reattachRestoredActiveTrace(); } @@ -104,6 +107,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { workspace.value = response.data?.workspace ?? current; messages.value = messagesFromWorkspace(workspace.value); currentRequest.value = null; + void hydrateTurnStatusAuthority(messages.value); await refreshSelectedSessionStatus(); await refreshConversations(selectedConversationIdFromWorkspace(workspace.value)); } @@ -117,6 +121,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { switchingConversationId.value = conversation.conversationId; workspace.value = optimisticWorkspaceSelection(current, conversation, tabProjectId); messages.value = messagesFromConversation(conversation); + void hydrateTurnStatusAuthority(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" }); @@ -126,6 +131,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (response.ok) { workspace.value = response.data?.workspace ?? current; messages.value = messagesFromWorkspaceSelection(workspace.value, [conversation, ...conversations.value]); + void hydrateTurnStatusAuthority(messages.value); void hydrateTerminalMessageDiagnostics(); reattachRestoredActiveTrace(); currentRequest.value = null; @@ -173,6 +179,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { workspace.value = response.data?.workspace ?? workspace.value; messages.value = messagesFromWorkspace(workspace.value); currentRequest.value = null; + void hydrateTurnStatusAuthority(messages.value); await refreshConversations(selectedConversationIdFromWorkspace(workspace.value)); } } @@ -180,7 +187,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function refreshConversations(includeConversationId: string | null = currentListIncludeConversationId()): Promise { const response = await api.workbench.conversations(activeProjectId.value, { includeConversationId }); if (response.ok) { - conversations.value = response.data?.conversations ?? []; + conversations.value = stableConversationList(conversations.value, response.data?.conversations, includeConversationId); conversationsReady.value = true; await refreshSessionStatusAuthority(conversations.value); return; @@ -227,6 +234,66 @@ export const useWorkbenchStore = defineStore("workbench", () => { }; } + async function hydrateTurnStatusAuthority(source: ChatMessage[] = messages.value): Promise { + await Promise.all(uniqueTraceIds(source).slice(-12).map((traceId) => refreshTurnStatusByTraceId(traceId))); + } + + async function refreshTurnStatusByTraceId(traceId: string | null | undefined): Promise { + const id = firstNonEmptyString(traceId); + if (!id) return; + const response = await api.agent.getAgentTurn(id, activeProjectId.value, 8000, () => activityRef.value); + if (response.ok && response.data) { + applyTurnStatusSnapshot(id, response.data); + return; + } + const next = { ...turnStatusAuthority.value }; + delete next[id]; + turnStatusAuthority.value = next; + } + + function applyTurnStatusSnapshot(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void { + rememberTurnStatus(traceId, result); + syncTurnStatusToMessage(traceId, result); + } + + function rememberTurnStatus(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void { + const id = firstNonEmptyString(result.traceId, traceId); + if (!id) return; + const status = normalizedStatusText(result.status) ?? null; + const running = (result as AgentChatResultResponse).running === true || isTraceActiveStatus(status); + const terminal = (result as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(status); + turnStatusAuthority.value = { + ...turnStatusAuthority.value, + [id]: { + traceId: id, + status, + running, + terminal, + conversationId: firstNonEmptyString((result as AgentChatResultResponse).conversationId) ?? null, + sessionId: firstNonEmptyString(result.sessionId) ?? null, + threadId: firstNonEmptyString(result.threadId) ?? null, + updatedAt: firstNonEmptyString((result as AgentChatResultResponse).updatedAt) ?? null, + loadedAt: new Date().toISOString() + } + }; + } + + function syncTurnStatusToMessage(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void { + const status = statusFromResult(result.status); + const terminal = (result as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(status); + messages.value = messages.value.map((message) => { + if (message.traceId !== traceId || message.role !== "agent") return message; + const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result as AgentChatResultResponse); + const error = normalizeAgentError((result as AgentChatResultResponse).error ?? runnerTrace?.error ?? message.error); + const agentRun = agentRunFromResult(result as AgentChatResultResponse, runnerTrace) ?? agentRunFromMessage(message); + const replyText = agentReplyText((result as AgentChatResultResponse).reply); + const text = terminal + ? firstNonEmptyString((result as AgentChatResultResponse).assistantText, finalResponseText((result as AgentChatResultResponse).finalResponse), replyText, (result as AgentChatResultResponse).text, (result as AgentChatResultResponse).summary, message.text, "Code Agent 已完成,但没有返回可展示的 final response。") ?? message.text + : message.text; + return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; + }); + } + async function submitMessage(text: string): Promise { const value = text.trim(); if (!value) return; @@ -261,6 +328,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { return; } void refreshSessionStatusById(sessionId); + applyTurnStatusSnapshot(traceId, response.data); void refreshConversations(conversationId); subscribe(traceId, response.data); } @@ -270,6 +338,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (!traceId) return; const response = await api.agent.cancelAgentMessage({ traceId, projectId: activeProjectId.value, sessionId: message.sessionId ?? selectedSessionId.value, threadId: message.threadId ?? selectedThreadId.value, conversationId: message.conversationId ?? activeConversationId.value }); const canceledStatus = workspaceSessionStatusFromChatStatus(firstNonEmptyString((response.data as Record | null)?.status, "canceled")); + applyTurnStatusSnapshot(traceId, { traceId, status: firstNonEmptyString((response.data as Record | null)?.status, "canceled"), running: false, terminal: true, sessionId: message.sessionId ?? selectedSessionId.value ?? undefined, threadId: message.threadId ?? selectedThreadId.value ?? undefined, conversationId: message.conversationId ?? activeConversationId.value ?? undefined } as AgentChatResultResponse); markMessage(traceId, { status: "canceled", text: "用户已取消该 turn。" }); void clearActiveTrace(traceId, "cancel-agent-message", canceledStatus); if (message.status === "running") chatPending.value = false; @@ -284,7 +353,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { activeConversationId: activeConversationId.value, targetTraceId: target.targetTraceId, targetSessionId: target.sessionId, - targetThreadId: target.threadId + targetThreadId: target.threadId, + turnStatusAuthority: turnStatusAuthority.value }); if (message) await cancelAgentMessage(message); } @@ -372,18 +442,19 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function reattachTrace(traceId: string): void { - const initial: AgentChatResponse = { status: "running", traceId, resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}` }; + const initial: AgentChatResponse = { status: "running", traceId, turnUrl: `/v1/agent/turns/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}` }; if (!messages.value.some((message) => message.traceId === traceId)) messages.value.push(makeMessage("agent", "", "running", { traceId, title: "Code Agent" })); subscribe(traceId, initial); } async function validateAndReattachTrace(traceId: string): Promise { - const result = await api.agent.getAgentChatResult(`/v1/agent/chat/result/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}`, 8000, () => activityRef.value); + const result = await api.agent.getAgentTurn(traceId, activeProjectId.value, 8000, () => activityRef.value); if (!result.ok || !result.data) { await clearActiveTrace(traceId, result.status === 404 ? "reattach-result-not-found" : "reattach-result-unavailable"); return; } - if (isTraceActiveStatus(result.data.status)) { + applyTurnStatusSnapshot(traceId, result.data); + if (result.data.running === true || isTraceActiveStatus(result.data.status)) { reattachTrace(traceId); return; } @@ -392,7 +463,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { function applyTraceSnapshot(traceId: string, snapshot: TraceSnapshot): void { const trace = snapshotToRunnerTrace(snapshot); - messages.value = messages.value.map((message) => message.traceId === traceId ? { ...message, runnerTrace: mergeRunnerTrace(message.runnerTrace, trace), updatedAt: new Date().toISOString() } : message); + rememberTurnStatus(traceId, snapshot); + const status = statusFromResult(snapshot.status); + messages.value = messages.value.map((message) => message.traceId === traceId ? { ...message, status, runnerTrace: mergeRunnerTrace(message.runnerTrace, trace), updatedAt: new Date().toISOString() } : message); void refreshSessionStatusById(trace.sessionId ?? selectedSessionId.value); } @@ -406,6 +479,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); return { ...message, status: terminalStatus, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; }); + rememberTurnStatus(traceId, result); chatPending.value = false; currentRequest.value = null; void clearActiveTrace(traceId, "trace-terminal", workspaceSessionStatusFromChatStatus(terminalStatus)); @@ -418,9 +492,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { await Promise.all(targets.map(async (message) => { const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId); if (!traceId) return; - const result = await api.agent.getAgentChatResult(`/v1/agent/chat/result/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}`, 8000, () => activityRef.value); + const result = await api.agent.getAgentTurn(traceId, activeProjectId.value, 8000, () => activityRef.value); if (!result.ok || !result.data) return; - applyTerminalResultDiagnostics(traceId, result.data); + applyTurnStatusSnapshot(traceId, result.data); })); } @@ -503,6 +577,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { } workspace.value = retried.data?.workspace ?? workspace.value; messages.value = messagesFromWorkspaceSelection(workspace.value, [conversation, ...conversations.value]); + void hydrateTurnStatusAuthority(messages.value); void hydrateTerminalMessageDiagnostics(); reattachRestoredActiveTrace(); currentRequest.value = null; @@ -516,7 +591,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function reattachRestoredActiveTrace(): void { - const traceId = firstNonEmptyString(activeTraceIdFromMessages(messages.value), activeTraceIdFromWorkspace(workspace.value)); + const traceId = firstNonEmptyString(activeTraceIdFromMessages(messages.value, turnStatusAuthority.value), activeTraceIdFromWorkspace(workspace.value)); if (traceId) void validateAndReattachTrace(traceId); } @@ -531,6 +606,10 @@ function uniqueSessionIds(conversations: ConversationRecord[]): string[] { return [...new Set(conversations.map((conversation) => sessionIdFromConversation(conversation)).filter((sessionId): sessionId is string => Boolean(sessionId)))]; } +function uniqueTraceIds(messages: ChatMessage[]): string[] { + return [...new Set(messages.map((message) => firstNonEmptyString(message.traceId, message.runnerTrace?.traceId)).filter((traceId): traceId is string => Boolean(traceId)))]; +} + function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: ConversationRecord, projectId: string): WorkspaceRecord { return { ...current, @@ -550,15 +629,14 @@ function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: Co } function messagesFromConversation(conversation: ConversationRecord): ChatMessage[] { - return (conversation.messages ?? []).map((message) => normalizeChatMessage(message, firstNonEmptyString(conversation.status, conversation.snapshot?.sessionStatus))); + return (conversation.messages ?? []).map((message) => normalizeChatMessage(message)); } function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] { const selected = workspace?.selectedConversation?.messages; const embedded = workspace?.workspace?.messages; const source = Array.isArray(selected) ? selected : Array.isArray(embedded) ? embedded : []; - const sessionStatus = firstNonEmptyString(workspace?.selectedConversation?.status, workspace?.selectedConversation?.snapshot?.sessionStatus, workspace?.workspace?.sessionStatus); - return source.map((message) => normalizeChatMessage(message, sessionStatus)); + return source.map((message) => normalizeChatMessage(message)); } function messagesFromWorkspaceSelection(workspace: WorkspaceRecord | null, fallbackConversations: ConversationRecord[]): ChatMessage[] { @@ -569,27 +647,20 @@ function messagesFromWorkspaceSelection(workspace: WorkspaceRecord | null, fallb return fallback ? messagesFromConversation(fallback) : []; } -function normalizeChatMessage(message: ChatMessage, sessionStatus?: string | null): ChatMessage { +function normalizeChatMessage(message: ChatMessage): ChatMessage { const text = firstNonEmptyString(message.text, messageText((message as Record).content), messageText((message as Record).message), finalResponseText((message as Record).finalResponse)) ?? ""; const runnerTrace = normalizeMessageRunnerTrace(message); const error = normalizeAgentError(message.error ?? runnerTrace?.error); const agentRun = agentRunFromMessage(message) ?? asAgentRun(runnerTrace?.agentRun); - const status = normalizeChatMessageStatus(message.status, runnerTrace, sessionStatus); + const status = normalizeChatMessageStatus(message.status); 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, runnerTrace: ChatMessage["runnerTrace"], sessionStatus?: string | null): ChatMessage["status"] { - const session = normalizedStatusText(sessionStatus); +function normalizeChatMessageStatus(value: unknown): ChatMessage["status"] { const explicit = normalizedStatusText(value); - const trace = normalizedStatusText(firstNonEmptyString(runnerTrace?.status, runnerTrace?.traceStatus)); - const sessionTerminal = terminalChatStatus(session); - if (sessionTerminal && !terminalChatStatus(explicit)) return sessionTerminal; - const traceTerminal = terminalChatStatus(trace); - if (traceTerminal && (!explicit || explicit === "idle" || explicit === "source" || isTraceActiveStatus(explicit))) return traceTerminal; const explicitTerminal = terminalChatStatus(explicit); if (explicitTerminal) return explicitTerminal; if (explicit === "sent") return "sent"; - if (isTraceActiveStatus(explicit) || isTraceActiveStatus(trace)) return "running"; if (explicit === "blocked") return "blocked"; if (explicit === "timeout") return "timeout"; if (explicit === "failed" || explicit === "error") return "failed"; @@ -606,11 +677,13 @@ function terminalChatStatus(value: string | null): ChatMessage["status"] | null return null; } -function activeTraceIdFromMessages(messages: ChatMessage[]): string | null { +function activeTraceIdFromMessages(messages: ChatMessage[], turnStatusAuthority: Record): string | null { for (const message of [...messages].reverse()) { if (message.role !== "agent") continue; - if (!isTraceActiveStatus(message.status) && !isTraceActiveStatus(message.runnerTrace?.status) && !isTraceActiveStatus(message.runnerTrace?.traceStatus)) continue; const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId); + if (!traceId) continue; + const turn = turnStatusAuthority[traceId]; + if (turn?.running !== true && !isTraceActiveStatus(turn?.status)) continue; if (traceId) return traceId; } return null; @@ -737,6 +810,11 @@ function finalResponseText(value: unknown): string | null { return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message)); } +function agentReplyText(value: AgentChatResultResponse["reply"]): string | null { + if (typeof value === "string") return value; + return value && typeof value === "object" ? firstNonEmptyString(value.content) : null; +} + function messageText(value: unknown): string | null { if (typeof value === "string") return value.trim() || null; if (typeof value === "number" || typeof value === "boolean") return String(value); @@ -761,10 +839,12 @@ function makeMessage(role: ChatMessage["role"], text: string, status: ChatMessag } function statusFromResult(status: string | undefined): ChatMessage["status"] { + if (isTraceActiveStatus(status)) return "running"; if (status === "blocked") return "blocked"; if (status === "timeout") return "timeout"; if (status === "canceled" || status === "cancelled") return "canceled"; if (status === "completed") return "completed"; + if (!status || status === "unknown") return "source"; return "failed"; } diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index fd101054..8d1e99aa 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -210,8 +210,14 @@ export interface AgentChatResponse { agentRun?: AgentRunProvenance; resultUrl?: string; traceUrl?: string; + turnUrl?: string; accepted?: boolean; shortConnection?: boolean; + running?: boolean; + terminal?: boolean; + updatedAt?: string; + lastEventLabel?: string; + waitingFor?: string; error?: string | { code?: string; message?: string; providerStatus?: number; [key: string]: unknown }; [key: string]: unknown; }