From 3724e82a637acb21c5ceca60bc5d15912726d31f Mon Sep 17 00:00:00 2001 From: lyon Date: Thu, 18 Jun 2026 13:34:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=94=B6=E6=95=9B=20Workbench=20se?= =?UTF-8?q?ssionId=20=E6=9D=83=E5=A8=81=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/hwlab-cloud-web/scripts/check.ts | 2 +- .../scripts/workbench-e2e-server.ts | 150 +++++-- .../workbench-r2-session-parity.test.ts | 26 +- .../scripts/workbench-server-state.test.ts | 8 +- web/hwlab-cloud-web/src/api/agent.ts | 78 +++- .../src/api/workbench-events.ts | 6 - web/hwlab-cloud-web/src/api/workbench.ts | 38 +- .../components/workbench/MessageActions.vue | 2 +- .../src/components/workbench/SessionRail.vue | 6 +- .../src/composables/useTraceSubscription.ts | 8 +- .../src/stores/workbench-projection.ts | 18 +- .../src/stores/workbench-session.ts | 39 -- web/hwlab-cloud-web/src/stores/workbench.ts | 423 +++++------------- web/hwlab-cloud-web/src/utils/index.ts | 33 -- .../src/views/workbench/CodeWorkbenchView.vue | 17 +- .../tests/workbench-e2e/fixtures/selectors.ts | 4 +- .../workbench-e2e/specs/deep-link.spec.ts | 7 +- .../specs/deleted-session-deeplink.spec.ts | 4 +- .../workbench-e2e/specs/event-replay.spec.ts | 10 +- .../specs/mobile-session-create.spec.ts | 2 +- .../specs/new-session-thread.spec.ts | 22 +- .../specs/project-boundary.spec.ts | 4 +- .../specs/session-switch-reload.spec.ts | 46 +- .../specs/stale-workspace-trace.spec.ts | 8 +- .../specs/status-consistency.spec.ts | 18 +- .../specs/trace-rendering.spec.ts | 2 +- 26 files changed, 368 insertions(+), 613 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index a45930fe..ce473f71 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -101,7 +101,7 @@ assertIncludes(workbenchStoreSource, "scheduleRealtimeGapHydration", "Workbench assertIncludes(workbenchStoreSource, "hydrateRealtimeGap", "Workbench realtime recovery must use REST snapshot/page gap fill"); assert.doesNotMatch(workbenchStoreSource, /subscribeToTrace|TRACE_POLL_INTERVAL_MS/u, "Workbench store must not reintroduce active trace polling"); assertIncludes(appSource, "/v1/workbench/events", "Workbench realtime client must use the RESTful same-origin events endpoint"); -assertIncludes(appSource, "/v1/agent/traces/", "trace hydration must use RESTful Cloud Web same-origin trace API"); +assertIncludes(appSource, "/v1/workbench/traces/", "trace hydration must use Workbench read-model trace API"); assert.doesNotMatch(appSource, /\/v1\/agent\/chat\/trace\//u, "Cloud Web must not use the legacy action-style chat trace API"); assert.doesNotMatch(appSource, /HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP/u, "early workspace bootstrap helper must not remain in Cloud Web app source"); assert.doesNotMatch(appSource, /\/auth\/workspace-bootstrap/u, "stale auth workspace bootstrap route must not remain in Cloud Web app source"); diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 0f462227..4e3e3528 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -26,6 +26,8 @@ interface ScenarioState { workspaceJson: JsonRecord; conversations: ConversationRecord[]; traces: Record; + requestLedger: JsonRecord[]; + legacyRequestLedger: JsonRecord[]; selectRequests: JsonRecord[]; workspacePatchRequests: JsonRecord[]; chatRequests: JsonRecord[]; @@ -68,6 +70,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) const method = request.method ?? "GET"; const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `127.0.0.1:${port}`}`); const path = url.pathname; + recordLedger(method, url); if (path === "/__e2e/health") return json(response, 200, { ok: true, scenarioId: state.scenarioId }); if (path === "/__e2e/state") return json(response, 200, stateSummary()); @@ -84,6 +87,20 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) await delay(state.conversationDelayMs); return json(response, 200, workbenchSessionListPayload(url)); } + const workbenchTurnMatch = path.match(/^\/v1\/workbench\/turns\/([^/]+)$/u); + if (workbenchTurnMatch && method === "GET") { + const traceId = decodeURIComponent(workbenchTurnMatch[1] ?? ""); + if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "turn_replay_unavailable" } }); + if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } }); + return json(response, 200, { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", turn: turnPayload(traceId) }); + } + const workbenchTraceMatch = path.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u); + if (workbenchTraceMatch && method === "GET") { + const traceId = decodeURIComponent(workbenchTraceMatch[1] ?? ""); + if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "trace_replay_unavailable" } }); + if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } }); + return json(response, 200, workbenchTracePayload(traceId, url)); + } const sessionMessagesMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u); if (sessionMessagesMatch && method === "GET") { const sessionId = decodeURIComponent(sessionMessagesMatch[1] ?? ""); @@ -98,31 +115,12 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) const conversation = workbenchConversationBySessionId(sessionId); return conversation ? json(response, 200, { session: conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); } - if (path === "/v1/workbench/workspace" && method === "GET") return json(response, 200, { workspace: workspacePayload() }); + if (path === "/v1/workbench/workspace") return legacyApiResponse(response, method, path); if (/^\/v1\/workbench\/workspace\/[^/]+$/u.test(path) && method === "PATCH") { - const body = await readJson(request); - state.workspacePatchRequests.push(redactRequestBody(body)); - applyWorkspacePatch(body); - return json(response, 200, { workspace: workspacePayload() }); + return legacyApiResponse(response, method, path); } if (/^\/v1\/workbench\/workspace\/[^/]+\/select-conversation$/u.test(path) && method === "POST") { - const body = await readJson(request); - state.selectRequests.push(redactRequestBody(body)); - const requestedProjectId = typeof body.projectId === "string" && body.projectId.trim() ? body.projectId.trim() : state.projectId; - if (requestedProjectId !== state.projectId) return json(response, 409, { ok: false, status: 409, error: { code: "workspace_project_mismatch" } }); - if (state.scenarioId === "server-authoritative-create" && body.create === true) { - const conversationId = "cnv_server_created"; - if (!conversationById(conversationId)) state.conversations.unshift(createConversationFromSelect({ ...body, sessionId: "ses_server_created", threadId: null }, conversationId)); - state.selectedConversationId = conversationId; - return json(response, 200, { workspace: workspacePayload() }); - } - const conversationId = String(body.conversationId ?? ""); - const conversation = conversationId ? conversationById(conversationId) : null; - if (conversation && conversationProjectId(conversation) !== requestedProjectId) return json(response, 404, { ok: false, status: 404, error: { code: "agent_conversation_not_found" } }); - if (conversation?.status === "archived") return json(response, 404, { ok: false, status: 404, error: { code: "agent_conversation_not_found" } }); - if (conversationId && !conversationById(conversationId) && body.create === true) state.conversations.unshift(createConversationFromSelect(body, conversationId)); - if (conversationId && conversationById(conversationId)) state.selectedConversationId = conversationId; - return json(response, 200, { workspace: workspacePayload() }); + return legacyApiResponse(response, method, path); } if (path === "/v1/agent/chat" && method === "POST") { @@ -131,27 +129,24 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) return json(response, 200, acceptChatTurn(body)); } + if (path === "/v1/agent/sessions" && method === "POST") { + const body = await readJson(request); + const conversation = createManualSessionConversation(body); + state.selectedConversationId = conversation.conversationId; + state.conversations.unshift(conversation); + return json(response, 200, { ok: true, status: "ok", session: conversation }); + } + if (path === "/v1/agent/conversations" && method === "GET") { - await delay(state.conversationDelayMs); - const include = url.searchParams.get("includeConversationId"); - const projectId = requestedProjectId(url); - const visibleConversations = state.conversations.filter((item) => conversationProjectId(item) === projectId); - const conversations = state.listOmitSelected && include ? visibleConversations.filter((item) => item.conversationId !== include) : visibleConversations; - return json(response, 200, { conversations: conversations.map((conversation) => conversationSummary(conversation)) }); + return legacyApiResponse(response, method, path); } const conversationMatch = path.match(/^\/v1\/agent\/conversations\/([^/]+)$/u); if (conversationMatch && method === "GET") { - const conversationId = decodeURIComponent(conversationMatch[1] ?? ""); - if (state.scenarioId === "session-switch-detail-404-isolated" && conversationId === "cnv_stale_404") return json(response, 404, { ok: false, status: 404, error: { code: "conversation_not_found" } }); - if (state.scenarioId === "completed-replay-detail-404" && conversationId === "cnv_completed") return json(response, 404, { ok: false, status: 404, error: { code: "conversation_replay_unavailable" } }); - const conversation = conversationById(conversationId); - if (conversation && conversationProjectMismatch(conversation, url)) return json(response, 404, { ok: false, status: 404, error: { code: "conversation_project_mismatch" } }); - return conversation ? json(response, 200, { conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "conversation_not_found" } }); + return legacyApiResponse(response, method, path); } - if (conversationMatch && method === "PUT") return json(response, 200, { ok: true, workspace: workspacePayload() }); + if (conversationMatch && method === "PUT") return legacyApiResponse(response, method, path); if (conversationMatch && method === "DELETE") { - deleteConversationById(decodeURIComponent(conversationMatch[1] ?? "")); - return json(response, 200, { ok: true, status: "deleted", workspace: workspacePayload() }); + return legacyApiResponse(response, method, path); } const sessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u); @@ -159,20 +154,12 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) const turnMatch = path.match(/^\/v1\/agent\/turns\/([^/]+)$/u); if (turnMatch && method === "GET") { - const traceId = decodeURIComponent(turnMatch[1] ?? ""); - if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "turn_replay_unavailable" } }); - if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } }); - if (traceProjectMismatch(traceId, url)) return json(response, 404, { ok: false, status: 404, error: { code: "trace_project_mismatch" } }); - return json(response, 200, turnPayload(traceId)); + return legacyApiResponse(response, method, path); } const traceMatch = path.match(/^\/v1\/agent\/traces\/([^/]+)$/u); if (traceMatch && method === "GET") { - const traceId = decodeURIComponent(traceMatch[1] ?? ""); - if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "trace_replay_unavailable" } }); - if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } }); - if (traceProjectMismatch(traceId, url)) return json(response, 404, { ok: false, status: 404, error: { code: "trace_project_mismatch" } }); - return json(response, 200, tracePayload(traceId, url)); + return legacyApiResponse(response, method, path); } if (path === "/v1/provider-profiles") return json(response, 200, { profiles: [{ profile: "codex-api", name: "Codex API", configured: true }, { profile: "deepseek", name: "DeepSeek", configured: true }] }); @@ -227,6 +214,8 @@ function createScenarioState(scenarioId: string): ScenarioState { workspaceJson: initialWorkspaceJson(base.projectId, base.providerProfile, selectedConversationId, conversations, staleNestedTraceId), conversations, traces, + requestLedger: [], + legacyRequestLedger: [], selectRequests: [], workspacePatchRequests: [], chatRequests: [], @@ -313,6 +302,27 @@ function createConversationFromSelect(body: JsonRecord, conversationId: string): }; } +function createManualSessionConversation(body: JsonRecord): ConversationRecord { + const now = new Date().toISOString(); + const token = state.scenarioId === "server-authoritative-create" ? "server_created" : Date.now().toString(36); + const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : `ses_${token}`; + const conversationId = typeof body.conversationId === "string" && body.conversationId.trim() ? body.conversationId : `cnv_${token}`; + const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null; + return { + conversationId, + projectId: state.projectId, + sessionId, + threadId, + status: "active", + startedAt: now, + updatedAt: now, + messageCount: 0, + firstUserMessagePreview: null, + session: { sessionId, threadId, status: "active" }, + messages: [] + }; +} + function crossProjectConversation(): ConversationRecord { const now = new Date().toISOString(); return { @@ -403,7 +413,8 @@ function archivedDeletedConversation(): ConversationRecord { } function acceptChatTurn(body: JsonRecord): JsonRecord { - const conversationId = typeof body.conversationId === "string" ? body.conversationId : state.selectedConversationId; + const requestedSession = typeof body.sessionId === "string" ? conversationBySessionId(body.sessionId) : null; + const conversationId = typeof body.conversationId === "string" ? body.conversationId : requestedSession?.conversationId ?? state.selectedConversationId; const sessionId = typeof body.sessionId === "string" ? body.sessionId : conversationById(conversationId)?.sessionId ?? `ses_${conversationId.slice(4)}`; const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null; const traceId = typeof body.traceId === "string" && body.traceId.trim() ? body.traceId : `trc_${Date.now().toString(16)}`; @@ -523,6 +534,27 @@ function tracePayload(traceId: string, url: URL): JsonRecord { return { ...turn, events: page, eventCount: events.length, hasMore, fullTraceLoaded: !hasMore, nextSinceSeq: hasMore ? lastSeq : null, range: { sinceSeq, returned: page.length, total: events.length } }; } +function workbenchTracePayload(traceId: string, url: URL): JsonRecord { + const payload = tracePayload(traceId, url); + return { + ok: true, + status: "ok", + contractVersion: "workbench-read-model-v1", + traceId, + traceStatus: payload.status, + events: payload.events, + eventCount: payload.eventCount, + hasMore: payload.hasMore, + nextSeq: payload.nextSinceSeq, + range: payload.range, + fullTraceLoaded: payload.fullTraceLoaded, + terminalEvidence: payload.terminalEvidence, + finalResponse: payload.finalResponse, + traceSummary: payload.traceSummary, + retention: payload.retention + }; +} + function sse(response: ServerResponse, url: URL): void { response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", @@ -567,7 +599,27 @@ function clearActiveTraceForSelection(): void { } function stateSummary(): JsonRecord { - return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, selectRequests: state.selectRequests, workspacePatchRequests: state.workspacePatchRequests, chatRequests: state.chatRequests, staleNestedTraceId: state.staleNestedTraceId, workspace: workspacePayload(), conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId })) }; + return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, requestLedger: state.requestLedger, legacyRequestLedger: state.legacyRequestLedger, selectRequests: state.selectRequests, workspacePatchRequests: state.workspacePatchRequests, chatRequests: state.chatRequests, staleNestedTraceId: state.staleNestedTraceId, workspace: workspacePayload(), conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId })) }; +} + +function recordLedger(method: string, url: URL): void { + if (!url.pathname.startsWith("/v1/")) return; + const entry = { method, path: url.pathname, query: Object.fromEntries(url.searchParams.entries()) }; + state.requestLedger.push(entry); + if (isLegacyWorkbenchPath(url.pathname)) state.legacyRequestLedger.push(entry); +} + +function isLegacyWorkbenchPath(path: string): boolean { + return path === "/v1/workbench/workspace" + || /^\/v1\/workbench\/workspace\/[^/]+(?:\/select-conversation)?$/u.test(path) + || path === "/v1/agent/conversations" + || /^\/v1\/agent\/conversations\/[^/]+$/u.test(path) + || /^\/v1\/agent\/turns\/[^/]+$/u.test(path) + || /^\/v1\/agent\/traces\/[^/]+$/u.test(path); +} + +function legacyApiResponse(response: ServerResponse, method: string, path: string): void { + return json(response, 410, { ok: false, status: 410, error: { code: "legacy_workbench_endpoint_forbidden", message: `${method} ${path} is not part of the sessionId Workbench contract.` } }); } function authPayload(): JsonRecord { 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 abede9c6..0d3ba700 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, normalizeChatMessageStatus, 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, shouldShowSessionListLoading, sortSessionTabs, stableConversationList } from "../src/stores/workbench-session.ts"; test("R2 composer ignores stale workspace activeTraceId and workspace session without verified active projection", () => { const workspace = workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" }); @@ -67,30 +67,6 @@ test("R2 composer never reuses a stale workspace session during routed selection assert.equal(withTarget.threadId, "thr_route"); }); -test("R2 active trace cleanup preserves workspace and records stale reason", () => { - const cleared = workspaceWithClearedActiveTrace(workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" }), "trc_stale", "steer-trace-not-found"); - assert.equal(cleared?.activeTraceId, null); - assert.equal(cleared?.workspace?.activeTraceId, null); - assert.equal(cleared?.workspace?.staleActiveTraceId, "trc_stale"); - assert.equal(cleared?.workspace?.staleActiveTraceReason, "steer-trace-not-found"); - assert.equal(cleared?.workspace?.sessionStatus, "failed"); -}); - -test("R2 workspace snapshots cannot override a newer explicit session selection", () => { - assert.equal(shouldApplyWorkspaceSnapshot({ - requestEpoch: 1, - currentEpoch: 2, - currentConversationId: "cnv_user_selected", - workspace: workspaceRecord({ activeTraceId: null, sessionStatus: "idle" }) - }), false); - assert.equal(shouldApplyWorkspaceSnapshot({ - requestEpoch: 1, - currentEpoch: 2, - currentConversationId: "cnv_r2", - workspace: workspaceRecord({ activeTraceId: null, sessionStatus: "idle" }) - }), true); -}); - test("R2 session list shows loading until conversations are ready", () => { assert.equal(shouldShowSessionListLoading({ loading: true, conversationsReady: false }), true); assert.equal(shouldShowSessionListLoading({ loading: true, conversationsReady: true }), false); diff --git a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts index 64e8f27a..85a01290 100644 --- a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { ConversationRecord, WorkspaceRecord } from "../src/types/index.ts"; -import { initialWorkbenchConversationIdFromLocation, nextExplicitWorkbenchConversationId, resolveWorkbenchActiveConversationId } from "../src/stores/workbench-projection.ts"; +import { initialWorkbenchSessionIdFromLocation, resolveWorkbenchActiveConversationId } from "../src/stores/workbench-projection.ts"; import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveConversation, selectActiveMessages, selectTraceAuthorityById } from "../src/stores/workbench-server-state.ts"; test("R5 active conversation comes only from route or explicit UI selection", () => { @@ -12,9 +12,9 @@ test("R5 active conversation comes only from route or explicit UI selection", () }); test("R5 initial route parsing ignores workspace fallback", () => { - assert.equal(initialWorkbenchConversationIdFromLocation({ pathname: "/workbench/sessions/cnv_route" }), "cnv_route"); - assert.equal(initialWorkbenchConversationIdFromLocation({ pathname: "/workbench" }), null); - assert.equal(nextExplicitWorkbenchConversationId("cnv_existing", "not-a-conversation"), "cnv_existing"); + assert.equal(initialWorkbenchSessionIdFromLocation({ pathname: "/workbench/sessions/ses_route" }), "ses_route"); + assert.equal(initialWorkbenchSessionIdFromLocation({ pathname: "/workbench/sessions/cnv_route" }), null); + assert.equal(initialWorkbenchSessionIdFromLocation({ pathname: "/workbench" }), null); }); test("R5 late workspace and trace snapshots update cache without changing active projection", () => { diff --git a/web/hwlab-cloud-web/src/api/agent.ts b/web/hwlab-cloud-web/src/api/agent.ts index 9e7901f7..7bfb6275 100644 --- a/web/hwlab-cloud-web/src/api/agent.ts +++ b/web/hwlab-cloud-web/src/api/agent.ts @@ -9,18 +9,86 @@ export interface AgentTraceRequestOptions { limit?: number | null; } -function agentTracePath(traceId: string, projectId: string, options: AgentTraceRequestOptions = {}): string { - const params = new URLSearchParams({ projectId }); +function workbenchTracePath(traceId: string, options: AgentTraceRequestOptions = {}): string { + const params = new URLSearchParams(); if (Number.isFinite(options.sinceSeq)) params.set("sinceSeq", String(Math.max(0, Math.trunc(Number(options.sinceSeq))))); if (Number.isFinite(options.limit)) params.set("limit", String(Math.max(1, Math.trunc(Number(options.limit))))); - return `/v1/agent/traces/${encodeURIComponent(traceId)}?${params.toString()}`; + const query = params.toString(); + return `/v1/workbench/traces/${encodeURIComponent(traceId)}/events${query ? `?${query}` : ""}`; } 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 }), - 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"], options?: AgentTraceRequestOptions): Promise> => fetchJson(agentTracePath(traceId, projectId, options), { timeoutMs, timeoutName: "Code Agent trace", activityRef }), + getAgentTurn: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise> => normalizeWorkbenchTurnResult(await fetchJson>(`/v1/workbench/turns/${encodeURIComponent(traceId)}`, { timeoutMs, timeoutName: "Code Agent turn", activityRef }), traceId), + getAgentTrace: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"], options?: AgentTraceRequestOptions): Promise> => normalizeWorkbenchTraceResult(await fetchJson>(workbenchTracePath(traceId, options), { timeoutMs, timeoutName: "Code Agent trace", activityRef }), traceId), + createAgentSession: (payload: Record = {}): Promise }>> => fetchJson("/v1/agent/sessions", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent session create" }), 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" }) }; + +function normalizeWorkbenchTurnResult(result: ApiResult>, traceId: string): ApiResult { + if (!result.ok || !result.data) return result as ApiResult; + const turn = recordValue(result.data.turn) ?? result.data; + return { + ...result, + data: { + ...turn, + traceId: textValue(turn.traceId) ?? traceId, + status: textValue(turn.status) ?? textValue(result.data.status) ?? "unknown", + running: turn.running === true, + terminal: turn.terminal === true, + sessionId: textValue(turn.sessionId) ?? undefined, + threadId: textValue(turn.threadId) ?? undefined, + conversationId: textValue(turn.conversationId) ?? undefined, + agentRun: recordValue(turn.agentRun) ?? undefined, + updatedAt: textValue(turn.updatedAt) ?? textValue(result.data.observedAt) ?? undefined + } as AgentChatResultResponse + }; +} + +function normalizeWorkbenchTraceResult(result: ApiResult>, traceId: string): ApiResult { + if (!result.ok || !result.data) return result as ApiResult; + const events = Array.isArray(result.data.events) ? result.data.events : []; + const nextSeq = Number(result.data.nextSeq ?? result.data.nextSinceSeq); + return { + ...result, + data: { + ...result.data, + traceId: textValue(result.data.traceId) ?? traceId, + status: textValue(result.data.traceStatus, result.data.status) ?? "unknown", + traceStatus: textValue(result.data.traceStatus, result.data.status) ?? undefined, + events, + traceEvents: events, + eventCount: Number.isFinite(Number(result.data.eventCount)) ? Number(result.data.eventCount) : events.length, + hasMore: result.data.hasMore === true, + fullTraceLoaded: result.data.hasMore !== true, + nextSinceSeq: Number.isFinite(nextSeq) ? Math.trunc(nextSeq) : null, + range: recordValue(result.data.range) ?? undefined, + runnerTrace: { + traceId: textValue(result.data.traceId) ?? traceId, + status: textValue(result.data.traceStatus, result.data.status) ?? undefined, + events, + eventCount: Number.isFinite(Number(result.data.eventCount)) ? Number(result.data.eventCount) : events.length, + hasMore: result.data.hasMore === true, + fullTraceLoaded: result.data.hasMore !== true, + nextSinceSeq: Number.isFinite(nextSeq) ? Math.trunc(nextSeq) : null, + range: recordValue(result.data.range) ?? undefined, + eventSource: "trace-api" + } + } as AgentChatResultResponse + }; +} + +function recordValue(value: unknown): Record | null { + return value && typeof value === "object" ? value as Record : null; +} + +function textValue(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value !== "string") continue; + const text = value.trim(); + if (text) return text; + } + return null; +} diff --git a/web/hwlab-cloud-web/src/api/workbench-events.ts b/web/hwlab-cloud-web/src/api/workbench-events.ts index febf9698..a1a906c6 100644 --- a/web/hwlab-cloud-web/src/api/workbench-events.ts +++ b/web/hwlab-cloud-web/src/api/workbench-events.ts @@ -34,9 +34,6 @@ export interface WorkbenchRealtimeEvent { } export interface WorkbenchEventStreamOptions { - projectId?: string | null; - workspaceId?: string | null; - conversationId?: string | null; sessionId?: string | null; traceId?: string | null; onEvent: (event: WorkbenchRealtimeEvent, eventName: string) => void; @@ -62,9 +59,6 @@ const WORKBENCH_EVENT_NAMES = [ export function connectWorkbenchEvents(options: WorkbenchEventStreamOptions): WorkbenchEventStream | null { if (typeof EventSource === "undefined") return null; const params = new URLSearchParams(); - appendParam(params, "projectId", options.projectId); - appendParam(params, "workspaceId", options.workspaceId); - appendParam(params, "conversationId", options.conversationId); appendParam(params, "sessionId", options.sessionId); appendParam(params, "traceId", options.traceId); const source = new EventSource(`/v1/workbench/events?${params.toString()}`, { withCredentials: true }); diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index e45e3eb3..1637af99 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -1,5 +1,5 @@ import { fetchJson } from "./client"; -import type { ApiResult, ChatMessage, ConversationRecord, WorkspaceRecord } from "@/types"; +import type { ApiResult, ChatMessage, ConversationRecord } from "@/types"; export interface WorkbenchSessionListResponse { projectId?: string | null; @@ -26,16 +26,6 @@ export interface SessionListOptions { timeoutMs?: number | null; } -export interface ConversationListOptions { - includeConversationId?: string | null; - timeoutMs?: number | null; -} - -export interface ConversationDetailOptions { - projectId?: string | null; - timeoutMs?: number | null; -} - export interface SessionMessageOptions { limit?: number | null; cursor?: string | null; @@ -44,20 +34,11 @@ export interface SessionMessageOptions { const SESSION_LIST_TIMEOUT_MS = 30_000; const SESSION_DETAIL_TIMEOUT_MS = 45_000; -const CONVERSATION_LIST_TIMEOUT_MS = 30_000; -const CONVERSATION_DETAIL_TIMEOUT_MS = 45_000; export const workbenchAPI = { sessions: (options: SessionListOptions = {}): Promise> => fetchJson(sessionListPath(options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_LIST_TIMEOUT_MS), timeoutName: "workbench sessions" }), session: (sessionId: string, timeoutMs: number | null = null): Promise> => fetchJson(`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: requestTimeoutMs(timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session" }), - sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" }), - workspace: (projectId: string): Promise> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }), - updateWorkspace: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }), - selectConversation: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }), - conversations: (projectId: string, options: ConversationListOptions = {}): Promise> => fetchJson(conversationListPath(projectId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, CONVERSATION_LIST_TIMEOUT_MS), timeoutName: "conversations" }), - conversation: (conversationId: string, options: ConversationDetailOptions = {}): Promise> => fetchJson(conversationDetailPath(conversationId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, CONVERSATION_DETAIL_TIMEOUT_MS), timeoutName: "conversation" }), - deleteConversation: (conversationId: string, payload: Record): Promise> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }), - saveConversation: (conversationId: string, payload: Record): Promise> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" }) + sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" }) }; function sessionListPath(options: SessionListOptions): string { @@ -77,21 +58,6 @@ function sessionMessagesPath(sessionId: string, options: SessionMessageOptions): return `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages${query ? `?${query}` : ""}`; } -function conversationDetailPath(conversationId: string, options: ConversationDetailOptions): string { - const params = new URLSearchParams(); - const projectId = typeof options.projectId === "string" ? options.projectId.trim() : ""; - if (projectId) params.set("projectId", projectId); - const query = params.toString(); - return `/v1/agent/conversations/${encodeURIComponent(conversationId)}${query ? `?${query}` : ""}`; -} - -function conversationListPath(projectId: string, options: ConversationListOptions): string { - const params = new URLSearchParams({ projectId }); - const includeConversationId = typeof options.includeConversationId === "string" ? options.includeConversationId.trim() : ""; - if (includeConversationId) params.set("includeConversationId", includeConversationId); - return `/v1/agent/conversations?${params.toString()}`; -} - function requestTimeoutMs(value: number | null | undefined, fallback: number): number { const timeout = Number(value); return Number.isFinite(timeout) && timeout > 0 ? Math.trunc(timeout) : fallback; diff --git a/web/hwlab-cloud-web/src/components/workbench/MessageActions.vue b/web/hwlab-cloud-web/src/components/workbench/MessageActions.vue index 77bdb591..0c128801 100644 --- a/web/hwlab-cloud-web/src/components/workbench/MessageActions.vue +++ b/web/hwlab-cloud-web/src/components/workbench/MessageActions.vue @@ -11,7 +11,7 @@ const emit = defineEmits<{ }>(); const traceId = computed(() => messageTraceId(props.message)); -const traceHref = computed(() => traceId.value ? `/v1/agent/traces/${encodeURIComponent(traceId.value)}` : "#"); +const traceHref = computed(() => traceId.value ? `/v1/workbench/traces/${encodeURIComponent(traceId.value)}/events` : "#"); const canCancel = computed(() => canCancelMessage(props.message)); const canRetry = computed(() => canRetryMessage(props.message)); const hasTrace = computed(() => props.message.role === "agent" && Boolean(traceId.value)); diff --git a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue index b3dcfa4a..adba74a5 100644 --- a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue +++ b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue @@ -15,11 +15,11 @@ const showSessionListLoading = computed(() => workbench.sessionListLoading); function copyActive(): void { const tab = activeTab.value; if (!tab) return; - void copy(`url=${sessionUrl(tab)}\nconversation=${tab.conversationId}\nsession=${tab.sessionId ?? ''}\nthread=${tab.threadId ?? ''}\ntrace=${tab.lastTraceId ?? ''}`); + void copy(`url=${sessionUrl(tab)}\nsession=${tab.sessionId ?? ''}\nthread=${tab.threadId ?? ''}\ntrace=${tab.lastTraceId ?? ''}`); } function sessionUrl(tab: { sessionId?: string | null; conversationId: string }): string { - const url = new URL(workbenchSessionPath(tab.sessionId || tab.conversationId), window.location.origin); + const url = new URL(workbenchSessionPath(tab.sessionId || ""), window.location.origin); return url.toString(); } @@ -85,7 +85,7 @@ function formatSessionUpdatedTime(value: string | null | undefined): string {