From d1a25f829ffe9372a48c47ad3116e35cfdf7b9d9 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 2 Jul 2026 17:07:22 +0000 Subject: [PATCH] feat(workbench): add metadata-only session detail --- internal/cloud/server-workbench-http.test.ts | 11 ++++++- internal/cloud/server-workbench-http.ts | 25 ++++++++++++---- internal/cloud/web-performance.test.ts | 20 +++++++++++-- internal/cloud/web-performance.ts | 16 +++++++++- .../scripts/workbench-performance.test.ts | 16 ++++++++++ .../workbench-realtime-runtime.test.ts | 9 ++++++ web/hwlab-cloud-web/src/api/workbench.ts | 30 ++++++++++++++++++- .../src/stores/workbench-colada-queries.ts | 9 ++++-- ...rkbench-message-projection-runtime.test.ts | 1 + web/hwlab-cloud-web/src/stores/workbench.ts | 2 +- .../src/utils/workbench-performance.ts | 15 +++++++++- 11 files changed, 139 insertions(+), 15 deletions(-) diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index 242b0c8f..30d91f53 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -660,11 +660,20 @@ test("workbench read model exposes session, messages, turn, and trace without wr assert.equal(staleProject.body.error.code, "workbench_authority_removed"); assert.equal(factQueries.length, 3); - const detail = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}`); + const detail = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}?includeMessages=false`); assert.equal(detail.status, 200); + assert.equal(detail.body.detailMode, "metadata-only"); + assert.equal(detail.body.messagesIncluded, false); assert.equal(detail.body.session.sessionId, session.id); assert.equal(detail.body.session.messagePageUrl, `/v1/workbench/sessions/${encodeURIComponent(session.id)}/messages`); assert.equal(detail.body.session.messageCount, 2); + assert.equal(Object.hasOwn(detail.body.session, "messages"), false); + + const fullDetail = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}?includeMessages=true`); + assert.equal(fullDetail.status, 200); + assert.equal(fullDetail.body.detailMode, "full"); + assert.equal(fullDetail.body.messagesIncluded, true); + assert.equal(fullDetail.body.session.messages.length, 2); const messages = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}/messages?limit=1`); assert.equal(messages.status, 200); diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 4eb621f7..31638963 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -82,7 +82,7 @@ export async function handleWorkbenchReadModelHttp(request, response, url, optio const sessionMatch = url.pathname.match(/^\/v1\/workbench\/sessions\/([^/]+)$/u); if (sessionMatch) { if (request.method !== "GET") return methodNotAllowed(response, "GET"); - await (perf ? perf.measure("workbench_session_detail", () => handleWorkbenchSessionDetail(request, response, options, auth.actor, decodeURIComponent(sessionMatch[1]))) : handleWorkbenchSessionDetail(request, response, options, auth.actor, decodeURIComponent(sessionMatch[1]))); + await (perf ? perf.measure("workbench_session_detail", () => handleWorkbenchSessionDetail(request, response, url, options, auth.actor, decodeURIComponent(sessionMatch[1]))) : handleWorkbenchSessionDetail(request, response, url, options, auth.actor, decodeURIComponent(sessionMatch[1]))); return; } @@ -1016,9 +1016,9 @@ function factSessionSummary(session, facts = {}) { }; } -function factSessionDetail(session, facts = {}) { +function factSessionDetail(session, facts = {}, options = {}) { const sessionId = factSessionId(session); - return { + const detail = { ...factSessionSummary(session, facts), metadata: { startedAt: session?.startedAt ?? session?.createdAt ?? null, @@ -1030,6 +1030,7 @@ function factSessionDetail(session, facts = {}) { valuesRedacted: true, secretMaterialStored: false }; + return options.includeMessages === true ? { ...detail, messages: factMessagesForSession(session, facts) } : detail; } function compactLaunchContext(value) { @@ -1862,8 +1863,9 @@ function uniqueText(values = []) { return [...new Set(values.map((value) => textValue(value)).filter(Boolean))]; } -async function handleWorkbenchSessionDetail(request, response, options, actor, sessionId) { +async function handleWorkbenchSessionDetail(request, response, url, options, actor, sessionId) { const readModel = workbenchRuntimeFactsReadModel(request, options, actor); + const includeMessages = includeMessagesForSessionDetail(url.searchParams); const result = await queryFactsByRouteId(readModel, sessionId, { families: WORKBENCH_SESSION_DETAIL_FAMILIES }); if (result.error) return sendJson(response, 503, workbenchProjectionStoreError(result.error)); const session = visibleFactSessions(result.facts, actor)[0] ?? null; @@ -1876,7 +1878,9 @@ async function handleWorkbenchSessionDetail(request, response, options, actor, s ok: true, status: "found", contractVersion: "workbench-session-detail-v1", - session: factSessionDetail(session, result.facts), + session: factSessionDetail(session, result.facts, { includeMessages }), + detailMode: includeMessages ? "full" : "metadata-only", + messagesIncluded: includeMessages, valuesRedacted: true, secretMaterialStored: false }); @@ -2927,6 +2931,17 @@ function boundedSessionListLimit(value) { return Math.min(MAX_PAGE_LIMIT, parsePositiveInteger(value, DEFAULT_SESSION_LIST_LIMIT)); } +function includeMessagesForSessionDetail(params) { + if (params.get("includeMessages") !== null) return truthyQueryFlag(params.get("includeMessages")); + if (params.get("messages") !== null) return truthyQueryFlag(params.get("messages")); + return false; +} + +function truthyQueryFlag(value) { + const normalized = textValue(value).toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + function cursorOffset(value) { const text = textValue(value); if (!text) return 0; diff --git a/internal/cloud/web-performance.test.ts b/internal/cloud/web-performance.test.ts index 52f711b1..49cacf52 100644 --- a/internal/cloud/web-performance.test.ts +++ b/internal/cloud/web-performance.test.ts @@ -359,10 +359,10 @@ test("web performance store accepts Workbench UI lifecycle events without high-c }); test("Workbench UI OTel spans include timeout activity diagnostics without raw trace ids", async () => { - const calls: any[] = []; + const calls: OtelExportRequestForTest[] = []; const originalFetch = globalThis.fetch; globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => { - calls.push(JSON.parse(String(init?.body ?? "{}"))); + calls.push(JSON.parse(String(init?.body ?? "{}")) as OtelExportRequestForTest); return new Response(null, { status: 200 }); }; try { @@ -393,7 +393,7 @@ test("Workbench UI OTel spans include timeout activity diagnostics without raw t assert.equal(result.submitted, 1); assert.equal(calls.length, 1); const span = calls[0].resourceSpans[0].scopeSpans[0].spans[0]; - const attrs = Object.fromEntries(span.attributes.map((item: any) => [item.key, item.value.stringValue ?? Number(item.value.intValue) ?? item.value.boolValue])); + const attrs = Object.fromEntries(span.attributes.map((item) => [item.key, item.value.stringValue ?? Number(item.value.intValue) ?? item.value.boolValue])); assert.equal(attrs["http.route"], "/v1/workbench/turns/:id"); assert.equal(attrs["workbench.ui.timeout_name"], "workbench turn"); assert.equal(attrs["workbench.ui.activity_waiting_for"], "code-agent"); @@ -405,6 +405,19 @@ test("Workbench UI OTel spans include timeout activity diagnostics without raw t } }); +interface OtelExportRequestForTest { + resourceSpans: Array<{ + scopeSpans: Array<{ + spans: Array<{ + attributes: Array<{ + key: string; + value: { stringValue?: string; intValue?: string | number; boolValue?: boolean }; + }>; + }>; + }>; + }>; +} + test("web performance store drops unsupported Workbench labels and counts series limit drops", () => { const store = createWebPerformanceStore({ env: { HWLAB_METRICS_NAMESPACE: "hwlab-v03", HWLAB_GITOPS_TARGET: "v03" }, maxSeries: 1 }); const first = store.record({ @@ -449,6 +462,7 @@ test("web performance store filters observability self-noise", () => { }); test("web performance route templates keep metrics low-cardinality", () => { + assert.equal(webPerformanceRouteTemplate("/v1/workbench/sessions/ses_abcdef?includeMessages=false"), "/v1/workbench/sessions/:id?includeMessages=false"); assert.equal(webPerformanceRouteTemplate("/v1/workbench/sessions/ses_abcdef/messages?traceId=trc_secret"), "/v1/workbench/sessions/:id/messages"); assert.equal(webPerformanceRouteTemplate("#/skills"), "/skills"); }); diff --git a/internal/cloud/web-performance.ts b/internal/cloud/web-performance.ts index dca9ac0e..63871aa1 100644 --- a/internal/cloud/web-performance.ts +++ b/internal/cloud/web-performance.ts @@ -920,7 +920,21 @@ export function webPerformanceRouteTemplate(input: unknown): string { } const parts = pathname.split("/").filter(Boolean).map((part) => routeSegmentTemplate(safeDecode(part))); const path = `/${parts.join("/")}`; - return path.length > 120 ? `${path.slice(0, 117)}...` : path; + const query = webPerformanceRouteQueryTemplate(normalizedRaw); + const route = `${path}${query}`; + return route.length > 120 ? `${route.slice(0, 117)}...` : route; +} + +function webPerformanceRouteQueryTemplate(input) { + try { + const url = new URL(input, "http://hwlab.local"); + if (!/^\/v1\/workbench\/sessions\/[^/]+$/u.test(url.pathname)) return ""; + if (url.searchParams.get("includeMessages") === "false") return "?includeMessages=false"; + if (url.searchParams.get("messages") === "0") return "?messages=0"; + } catch { + // Keep metric labels bounded when route samples are not parseable URLs. + } + return ""; } function sampleLabels(labels: Record) { diff --git a/web/hwlab-cloud-web/scripts/workbench-performance.test.ts b/web/hwlab-cloud-web/scripts/workbench-performance.test.ts index bf5e03a9..f243de0d 100644 --- a/web/hwlab-cloud-web/scripts/workbench-performance.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-performance.test.ts @@ -230,6 +230,22 @@ test("Workbench API timing enriches from ResourceTiming and clears the browser b } }); +test("Workbench API timing labels metadata-only session detail distinctly", () => { + resetWorkbenchPerformanceForTest(); + const restoreBrowserRuntime = installBrowserProbeRuntime(); + try { + const startedAtEpochMs = 1_000_000; + recordWorkbenchApiRequest({ route: "/v1/workbench/sessions/ses_secret?includeMessages=false", method: "GET", status: 200, outcome: "ok", startedAtEpochMs, endedAtEpochMs: startedAtEpochMs + 12 }); + + const events = drainWorkbenchPerformanceEventsForTest(); + const event = events.find((item) => item.kind === "workbench_ui_event" && item.eventType === "api_request"); + assert.equal(event?.route, "/v1/workbench/sessions/:id?includeMessages=false"); + } finally { + resetWorkbenchPerformanceForTest(); + restoreBrowserRuntime(); + } +}); + test("Workbench session switch treats visible empty sessions as successful first paint", () => { resetWorkbenchPerformanceForTest(); startWorkbenchSessionSwitch({ sessionId: "ses_empty_visible", source: "rail", targetState: "empty", cache: "cold" }); diff --git a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts index d015e3c0..160c522c 100644 --- a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts @@ -5,6 +5,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { ChatMessage } from "../src/types/index.ts"; +import { workbenchSessionDetailPathForTest, workbenchSessionMessagesPathForTest } from "../src/api/workbench.ts"; import type { WorkbenchStreamTransportRecovery } from "../src/utils/workbench-realtime-runtime.ts"; import { workbenchRuntimePolicy } from "../src/config/workbench-runtime-policy.ts"; import { AsyncQueue, work } from "../src/utils/scheduler/async-queue.ts"; @@ -55,6 +56,14 @@ test("Workbench runtime policy reads injected config while preserving defaults", assert.equal(policy.defaultGatewayTimeoutMs, 120_000); }); +test("Workbench API uses metadata-only session detail and bounded messages paths independently", () => { + assert.equal(workbenchSessionDetailPathForTest("ses_metadata"), "/v1/workbench/sessions/ses_metadata?includeMessages=false"); + assert.equal(workbenchSessionDetailPathForTest("ses_metadata", { timeoutMs: 8000 }), "/v1/workbench/sessions/ses_metadata?includeMessages=false"); + assert.equal(workbenchSessionDetailPathForTest("ses_metadata", 8000), "/v1/workbench/sessions/ses_metadata?includeMessages=false"); + assert.equal(workbenchSessionDetailPathForTest("ses_metadata", { includeMessages: true }), "/v1/workbench/sessions/ses_metadata?includeMessages=true"); + assert.equal(workbenchSessionMessagesPathForTest("ses_metadata", { limit: 9 }), "/v1/workbench/sessions/ses_metadata/messages?limit=9"); +}); + test("Error runtime owns Workbench message diagnostic view model", () => { const degraded = messageDiagnosticView(agentMessage({ status: "running", diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index 61535ae8..e19e14ca 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -38,6 +38,11 @@ export interface SessionMessageOptions { timeoutMs?: number | null; } +export interface SessionDetailOptions { + includeMessages?: boolean | null; + timeoutMs?: number | null; +} + export interface WorkbenchTraceRequestOptions { afterProjectedSeq?: number | null; limit?: number | null; @@ -119,12 +124,23 @@ const SESSION_DETAIL_TIMEOUT_MS = 45_000; export const workbenchAPI = { launch: (input: WorkbenchLaunchRequest): Promise> => fetchJson("/v1/workbench/launches", { method: "POST", body: JSON.stringify(input), timeoutMs: 30000, timeoutName: "workbench launch" }), 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" }), + session: (sessionId: string, options: SessionDetailOptions | number | null = {}): Promise> => { + const detailOptions = sessionDetailOptions(options); + return fetchJson(sessionDetailPath(sessionId, detailOptions), { timeoutMs: requestTimeoutMs(detailOptions.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" }), turn: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise> => normalizeWorkbenchTurnResult(await fetchJson>(`/v1/workbench/turns/${encodeURIComponent(traceId)}`, { timeoutMs, timeoutName: "workbench turn", activityRef }), traceId), traceEvents: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"], options?: WorkbenchTraceRequestOptions): Promise> => normalizeWorkbenchTraceResult(await fetchJson>(workbenchTracePath(traceId, options), { timeoutMs, timeoutName: "workbench trace", activityRef }), traceId) }; +export function workbenchSessionDetailPathForTest(sessionId: string, options: SessionDetailOptions | number | null = {}): string { + return sessionDetailPath(sessionId, sessionDetailOptions(options)); +} + +export function workbenchSessionMessagesPathForTest(sessionId: string, options: SessionMessageOptions = {}): string { + return sessionMessagesPath(sessionId, options); +} + function sessionListPath(options: SessionListOptions): string { const params = new URLSearchParams(); const includeSessionId = typeof options.includeSessionId === "string" ? options.includeSessionId.trim() : ""; @@ -145,6 +161,18 @@ function sessionMessagesPath(sessionId: string, options: SessionMessageOptions): return `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages${query ? `?${query}` : ""}`; } +function sessionDetailPath(sessionId: string, options: SessionDetailOptions): string { + const params = new URLSearchParams(); + params.set("includeMessages", options.includeMessages === true ? "true" : "false"); + return `/v1/workbench/sessions/${encodeURIComponent(sessionId)}?${params.toString()}`; +} + +function sessionDetailOptions(input: SessionDetailOptions | number | null | undefined): SessionDetailOptions { + if (typeof input === "number") return { timeoutMs: input, includeMessages: false }; + if (!input || typeof input !== "object") return { includeMessages: false }; + return { ...input, includeMessages: input.includeMessages === true }; +} + 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/stores/workbench-colada-queries.ts b/web/hwlab-cloud-web/src/stores/workbench-colada-queries.ts index 684dd667..4aec2256 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-colada-queries.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-colada-queries.ts @@ -23,10 +23,15 @@ export interface WorkbenchTraceEventsQueryOptions extends QueryRunOptions, Workb activityRef?: ApiRequestOptions["activityRef"]; } +export interface WorkbenchSessionDetailQueryOptions extends QueryRunOptions { + timeoutMs?: number | null; + includeMessages?: false | null; +} + export interface WorkbenchColadaQueries { queryCache: QueryCache; fetchSessions: (options?: SessionListOptions & QueryRunOptions) => Promise>; - fetchSession: (sessionId: string, options?: QueryRunOptions & { timeoutMs?: number | null }) => Promise>; + fetchSession: (sessionId: string, options?: WorkbenchSessionDetailQueryOptions) => Promise>; fetchSessionMessages: (sessionId: string, options?: SessionMessageOptions & QueryRunOptions) => Promise>; fetchTurn: (traceId: string, options?: WorkbenchTurnQueryOptions) => Promise>; fetchTraceEvents: (traceId: string, options?: WorkbenchTraceEventsQueryOptions) => Promise>; @@ -45,7 +50,7 @@ export function useWorkbenchColadaQueries(): WorkbenchColadaQueries { return { queryCache, fetchSessions: (options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.sessions(options), () => api.workbench.sessions(options), options), - fetchSession: (sessionId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.session(sessionId), () => api.workbench.session(sessionId, options.timeoutMs ?? null), options), + fetchSession: (sessionId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.session(sessionId), () => api.workbench.session(sessionId, { timeoutMs: options.timeoutMs ?? null, includeMessages: false }), options), fetchSessionMessages: (sessionId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.sessionMessages(sessionId, options), () => api.workbench.sessionMessages(sessionId, options), options), fetchTurn: (traceId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.turn(traceId), () => api.workbench.turn(traceId, options.timeoutMs ?? 8000, options.activityRef), options), fetchTraceEvents: (traceId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.traceEvents(traceId, options), () => api.workbench.traceEvents(traceId, options.timeoutMs ?? 8000, options.activityRef, { afterProjectedSeq: options.afterProjectedSeq, limit: options.limit }), options), diff --git a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts index d1e3616b..4654eed8 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts @@ -155,6 +155,7 @@ test("workbench active terminal paths seal final response from turn authority", assert.match(realtimeSessionDetailBlock, /traceTerminalBodyIsVisible\(traceId, id\)[\s\S]*terminal_low_priority_session_detail_skip[\s\S]*return/u); assert.match(realtimeSessionDetailBlock, /traceTerminalBodyIsVisible\(traceId, id\)[\s\S]*terminal_low_priority_session_detail_apply_skip[\s\S]*return/u); assert.match(loadBlock, /const messageLimit = sessionMessageProjectionWindowLimit\(\);/u); + assert.match(loadBlock, /fetchSession\(requestId, \{ includeMessages: false,/u); assert.match(loadBlock, /sessionFromWorkbenchSession\(detail\.data\?\.session, \{ includeMessages: false \}\)/u); assert.match(loadBlock, /const fallbackMessages = seed\?\.sessionId === id \? seed\.messages \?\? \[\] : \[\];/u); assert.doesNotMatch(loadBlock, /limit:\s*100/u); diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 17dc17b4..2f9fe0e3 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -1704,7 +1704,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const normalizedRequestId = normalizeWorkbenchSessionId(requestId); const messageLimit = sessionMessageProjectionWindowLimit(); const eagerMessages = normalizedRequestId ? workbenchColadaQueries.fetchSessionMessages(normalizedRequestId, { limit: messageLimit, force: true, minIntervalMs: runtimePolicy.workbenchSessionMessagesMinRefreshMs }) : null; - const detail = await workbenchColadaQueries.fetchSession(requestId, { force: true, minIntervalMs: runtimePolicy.workbenchSessionDetailMinRefreshMs }); + const detail = await workbenchColadaQueries.fetchSession(requestId, { includeMessages: false, force: true, minIntervalMs: runtimePolicy.workbenchSessionDetailMinRefreshMs }); if (!detail.ok) return null; const detailSession = sessionFromWorkbenchSession(detail.data?.session, { includeMessages: false }); const id = detailSession?.sessionId ?? normalizedRequestId ?? seed?.sessionId; diff --git a/web/hwlab-cloud-web/src/utils/workbench-performance.ts b/web/hwlab-cloud-web/src/utils/workbench-performance.ts index 15b933a6..69d957ac 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-performance.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-performance.ts @@ -786,7 +786,20 @@ function routeTemplate(input: string): string { const raw = safeText(input) || "/workbench"; const pathOnly = raw.startsWith("#") ? raw.slice(1) || "/" : raw.split(/[?#]/u, 1)[0] || "/"; const segments = pathOnly.split("/").filter(Boolean).map((part) => routeSegmentTemplate(part)); - return `/${segments.join("/")}`; + const path = `/${segments.join("/")}`; + return `${path}${routeQueryTemplate(raw)}`; +} + +function routeQueryTemplate(input: string): string { + try { + const url = new URL(input.startsWith("#") ? input.slice(1) || "/" : input, "https://hwlab.local"); + if (!/^\/v1\/workbench\/sessions\/[^/]+$/u.test(url.pathname)) return ""; + if (url.searchParams.get("includeMessages") === "false") return "?includeMessages=false"; + if (url.searchParams.get("messages") === "0") return "?messages=0"; + } catch { + // Keep route templates low-cardinality when the URL is not parseable. + } + return ""; } function routeSegmentTemplate(segment: string): string {