diff --git a/apps/web/docs/CONVENTIONS.md b/apps/web/docs/CONVENTIONS.md index 609b1f9be3b..852a541ca35 100644 --- a/apps/web/docs/CONVENTIONS.md +++ b/apps/web/docs/CONVENTIONS.md @@ -154,7 +154,7 @@ References: ### Organize by domain, not technical layer -Group code by what it does (messages, conversations, streaming, +Group code by what it does (messages, conversations, voice, interactions), not by what it is (hooks, utils, components). The top-level folder for domain modules is called **`domains/`**. @@ -181,15 +181,6 @@ src/ conversation-queries.ts use-conversation-loader.ts types.ts - streaming/ # SSE transport, event parsing - stream-store.ts - stream-transport.ts - event-parser.ts - event-types.ts - handlers/ - message-handlers.ts - interaction-handlers.ts - types.ts chat/ # chat feature module turn-store.ts # turn-level state machine turn-coordinator.ts # atomic turn-store + conversation-store transitions @@ -207,6 +198,7 @@ src/ auth/ # allauth client, CSRF, auth middleware feature-flags/ # feature flag provider sync/ # server state sync (tag registry, router) + streaming/ # SSE transport, event parsing, debug tracking api-client.ts # HeyAPI configured client + interceptors telemetry/ # client identity for daemon registration runtime/ # framework adapters, platform bridges @@ -220,7 +212,7 @@ src/ This app uses `domains/` over the more common `features/` because "features" implies product-level concepts (like "chat" or "settings") that contain multiple domains. `messages`, -`conversations`, and `streaming` are business domains with distinct +`conversations`, and `voice` are business domains with distinct data models and lifecycles — not features. `domains/` is more precise for a DDD-influenced architecture and signals that each folder represents a bounded context. @@ -306,7 +298,7 @@ Examples of correct splits: - `messages/` vs `conversations/`: messages are created, streamed, delta-updated, and compacted — different lifecycle from conversation CRUD and grouping. -- `streaming/` vs `messages/`: SSE transport and reconnection logic +- `lib/streaming/` vs `messages/`: SSE transport and reconnection logic changes for different reasons than message state management. - `chat/interaction-store` vs `chat/turn-store`: user-facing prompts (secrets, confirmations) have their own state machine, independent @@ -384,7 +376,7 @@ owns it. | `hooks/` | Cross-domain React hooks | `use-is-mobile.ts`, `use-visible-viewport.ts`, `use-feature-flag-bus-sync.ts` | | `utils/` | Pure utility functions (no side effects, no third-party SDKs) | `format.ts`, `browser.ts`, `network-status.ts`, `stable-id.ts` | | `types/` | Shared type definitions | `window.d.ts`, `api-types.ts` | -| `lib/` | Third-party integrations and infrastructure wrappers (have side effects, configure SDK instances, manage lifecycle) | `sentry/` (error reporting), `auth/` (allauth + CSRF), `feature-flags/` (catalog + registry), `sync/` (state sync), `api-client.ts` (HeyAPI) | +| `lib/` | Third-party integrations and infrastructure wrappers (have side effects, configure SDK instances, manage lifecycle) | `sentry/` (error reporting), `auth/` (allauth + CSRF), `feature-flags/` (catalog + registry), `sync/` (state sync), `streaming/` (SSE transport), `diagnostics.ts` (session ring buffer), `api-client.ts` (HeyAPI) | | `runtime/` | Framework adapters and native platform bridges | `route-adapter.ts`, `native-auth.ts`, `native-deep-link.ts`, `app-bridge.ts` | | `components/` | Cross-domain shared UI | `error-boundary.tsx`, `sign-in-gate.tsx`, `providers.tsx` | diff --git a/apps/web/docs/STYLE_GUIDE.md b/apps/web/docs/STYLE_GUIDE.md index 05395d523bf..0471eaea3a0 100644 --- a/apps/web/docs/STYLE_GUIDE.md +++ b/apps/web/docs/STYLE_GUIDE.md @@ -69,13 +69,14 @@ src/ chat/ # chat feature (turn, subagent, interaction stores) messages/ # message lifecycle conversations/ # conversation CRUD, grouping, selection - streaming/ # SSE transport, event parsing voice/ # STT, TTS, PTT ... hooks/ # cross-domain shared hooks utils/ # cross-domain shared utilities (pure functions) types/ # cross-domain shared types lib/ # configured third-party wrappers (API client, Sentry, CSRF) + streaming/ # SSE transport, event parsing, debug tracking + diagnostics.ts # session diagnostics ring buffer runtime/ # framework adapters, platform bridges components/ # cross-domain shared UI generated/ # auto-generated code (HeyAPI, catalogs) diff --git a/apps/web/src/components/share-feedback-modal.tsx b/apps/web/src/components/share-feedback-modal.tsx index 458a8086d50..c14482635e3 100644 --- a/apps/web/src/components/share-feedback-modal.tsx +++ b/apps/web/src/components/share-feedback-modal.tsx @@ -35,7 +35,7 @@ import { feedbackCreateMutation } from "@/generated/api/@tanstack/react-query.ge import type { ClassificationEnum } from "@/generated/api/types.gen"; import { buildVellumMutatingHeaders } from "@/lib/auth/request-headers"; import type { ChatDebugApi } from "@/domains/chat/utils/debug-api"; -import { buildChatDiagnosticsSnapshot } from "@/domains/chat/utils/diagnostics"; +import { buildDiagnosticsSnapshot } from "@/lib/diagnostics"; import { isElectron } from "@/runtime/is-electron"; import { useAuthStore } from "@/stores/auth-store"; import { VELLUM_COMMUNITY_URL } from "@/utils/external-urls"; @@ -208,7 +208,7 @@ async function buildClientLogsFile( } catch { currentChatState = null; } - const chatDiagnostics = buildChatDiagnosticsSnapshot(currentChatState); + const chatDiagnostics = buildDiagnosticsSnapshot(currentChatState); const payload = { collected_at: now.toISOString(), time_range: timeRange, diff --git a/apps/web/src/domains/chat/api/client.ts b/apps/web/src/domains/chat/api/client.ts deleted file mode 100644 index 50de17c9433..00000000000 --- a/apps/web/src/domains/chat/api/client.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Shared HTTP client configuration for chat API domain modules. - * - * Re-exports the HeyAPI-generated client, standard error utilities, and the - * SDK base options constant so each domain module imports from a single - * location instead of duplicating the setup. - */ - -// Side-effect import to configure the default HeyAPI client. -// We're using the raw `fetch` client here for legacy reasons. -// You should typically use the tanstack-query provider which ensures the client is configured. - -export { client } from "@/generated/api/client.gen"; -export { - ApiError, - assertHasResponse, - extractErrorMessage, - SDK_BASE_OPTIONS, -} from "@/utils/api-errors"; diff --git a/apps/web/src/domains/chat/api/debug-api.ts b/apps/web/src/domains/chat/api/debug-api.ts index 8bba1779c39..d75b668512e 100644 --- a/apps/web/src/domains/chat/api/debug-api.ts +++ b/apps/web/src/domains/chat/api/debug-api.ts @@ -15,14 +15,12 @@ * namespace, so both halves of the debug API mount/unmount together. */ -import type { - SseDebugClient, - SseDebugEventEntry, -} from "@/domains/chat/api/stream-debug"; import { + type SseDebugClient, + type SseDebugEventEntry, getSseClients, getSseEvents, -} from "@/domains/chat/api/stream-debug"; +} from "@/lib/streaming/stream-debug"; export interface ChatDebugEventsApi { /** Snapshot of currently-live SSE clients. */ diff --git a/apps/web/src/domains/chat/api/history.ts b/apps/web/src/domains/chat/api/history.ts index ceb903502e6..24b51f77df1 100644 --- a/apps/web/src/domains/chat/api/history.ts +++ b/apps/web/src/domains/chat/api/history.ts @@ -15,10 +15,8 @@ import { assertHasResponse, extractErrorMessage, } from "@/utils/api-errors"; -import { - recordChatDiagnostic, - summarizeDisplayMessages, -} from "@/domains/chat/utils/diagnostics"; +import { recordDiagnostic } from "@/lib/diagnostics"; +import { summarizeDisplayMessages } from "@/domains/chat/utils/diagnostics"; import { mapRuntimeToDisplayMessage } from "@/domains/chat/utils/map-runtime-message"; import { dedupeDisplayMessages } from "@/domains/chat/utils/reconcile"; @@ -119,7 +117,7 @@ async function fetchPaginatedHistory( assertHasResponse(response, error, "Failed to fetch history"); if (!response.ok) { - recordChatDiagnostic("history_page_fetch_error", { + recordDiagnostic("history_page_fetch_error", { assistantId, query, status: response.status, @@ -133,7 +131,7 @@ async function fetchPaginatedHistory( } const result = parsePaginatedResponse(data ?? {}); - recordChatDiagnostic("history_page_fetch", { + recordDiagnostic("history_page_fetch", { assistantId, query, status: response.status, diff --git a/apps/web/src/domains/chat/api/interactions.ts b/apps/web/src/domains/chat/api/interactions.ts index 4b536687b85..8993e790cb3 100644 --- a/apps/web/src/domains/chat/api/interactions.ts +++ b/apps/web/src/domains/chat/api/interactions.ts @@ -7,12 +7,12 @@ import type { ConfirmationDecision } from "@/types/event-types"; import type { QuestionSubmission } from "@/domains/chat/api/event-types"; +import { client } from "@/generated/api/client.gen"; import { assertHasResponse, - client, extractErrorMessage, SDK_BASE_OPTIONS, -} from "@/domains/chat/api/client"; +} from "@/utils/api-errors"; export async function getPendingInteractions( assistantId: string, diff --git a/apps/web/src/domains/chat/api/messages.test.ts b/apps/web/src/domains/chat/api/messages.test.ts index 9fd11a1ae81..a6fd0da5077 100644 --- a/apps/web/src/domains/chat/api/messages.test.ts +++ b/apps/web/src/domains/chat/api/messages.test.ts @@ -9,7 +9,7 @@ */ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { client } from "@/domains/chat/api/client"; +import { client } from "@/generated/api/client.gen"; import { getChatHistory, normalizeContentOrder, normalizeTextSegments, postChatMessage } from "@/domains/chat/api/messages"; // --------------------------------------------------------------------------- diff --git a/apps/web/src/domains/chat/api/messages.ts b/apps/web/src/domains/chat/api/messages.ts index 49b9256c43f..6eefd24d033 100644 --- a/apps/web/src/domains/chat/api/messages.ts +++ b/apps/web/src/domains/chat/api/messages.ts @@ -13,12 +13,12 @@ import type { SlackRuntimeMessage, Surface, } from "@/domains/chat/types/types"; +import { client } from "@/generated/api/client.gen"; import { assertHasResponse, - client, extractErrorMessage, SDK_BASE_OPTIONS, -} from "@/domains/chat/api/client"; +} from "@/utils/api-errors"; import { normalizePreChatOnboardingContext, type PreChatOnboardingContext, diff --git a/apps/web/src/domains/chat/api/slack-channel-name.ts b/apps/web/src/domains/chat/api/slack-channel-name.ts index b6fa580b2d6..e9b7a58eefe 100644 --- a/apps/web/src/domains/chat/api/slack-channel-name.ts +++ b/apps/web/src/domains/chat/api/slack-channel-name.ts @@ -1,4 +1,5 @@ -import { client, SDK_BASE_OPTIONS } from "@/domains/chat/api/client"; +import { client } from "@/generated/api/client.gen"; +import { SDK_BASE_OPTIONS } from "@/utils/api-errors"; export interface SlackChannelNameResolution { channelId: string; diff --git a/apps/web/src/domains/chat/api/surfaces.ts b/apps/web/src/domains/chat/api/surfaces.ts index 75217ba7bfe..fae1564b2c7 100644 --- a/apps/web/src/domains/chat/api/surfaces.ts +++ b/apps/web/src/domains/chat/api/surfaces.ts @@ -2,12 +2,12 @@ * Surface action submission, content fetching, and artifact download. */ +import { client } from "@/generated/api/client.gen"; import { assertHasResponse, - client, extractErrorMessage, SDK_BASE_OPTIONS, -} from "@/domains/chat/api/client"; +} from "@/utils/api-errors"; export async function submitSurfaceAction( assistantId: string, diff --git a/apps/web/src/domains/chat/api/threshold-api.test.ts b/apps/web/src/domains/chat/api/threshold-api.test.ts index bb904511395..bc5be3b9cf0 100644 --- a/apps/web/src/domains/chat/api/threshold-api.test.ts +++ b/apps/web/src/domains/chat/api/threshold-api.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; -import { client } from "@/domains/chat/api/client"; +import { client } from "@/generated/api/client.gen"; import { getConversationOverride } from "@/lib/threshold-api"; // --------------------------------------------------------------------------- diff --git a/apps/web/src/domains/chat/chat-page.tsx b/apps/web/src/domains/chat/chat-page.tsx index 6029957c028..89c9dfdaf17 100644 --- a/apps/web/src/domains/chat/chat-page.tsx +++ b/apps/web/src/domains/chat/chat-page.tsx @@ -134,7 +134,7 @@ import { import { getEditChatConversationId, setEditChatConversationId } from "@/domains/chat/utils/edit-chat-session"; import { routes } from "@/utils/routes"; import { haptic } from "@/utils/haptics"; -import type { ChatEventStream } from "@/domains/chat/api/stream"; +import type { ChatEventStream } from "@/lib/streaming/stream-transport"; import { ChatRouteContent, type ChatRouteContentProps, diff --git a/apps/web/src/domains/chat/components/chat-route-content.tsx b/apps/web/src/domains/chat/components/chat-route-content.tsx index 1cade3919b8..3a3c82586c2 100644 --- a/apps/web/src/domains/chat/components/chat-route-content.tsx +++ b/apps/web/src/domains/chat/components/chat-route-content.tsx @@ -121,7 +121,7 @@ import { DiskPressureBanner, type DiskPressureBannerMode } from "@/domains/chat/ import type { VoiceInputButtonHandle } from "@/domains/chat/components/voice-input-button"; import type { Conversation } from "@/types/conversation-types"; import { submitQuestionResponse } from "@/domains/chat/api/interactions"; -import type { ChatEventStream } from "@/domains/chat/api/stream"; +import type { ChatEventStream } from "@/lib/streaming/stream-transport"; // --------------------------------------------------------------------------- // Types diff --git a/apps/web/src/domains/chat/components/slack-channel-footer.test.tsx b/apps/web/src/domains/chat/components/slack-channel-footer.test.tsx index a1d8a36bd28..7b7596b515a 100644 --- a/apps/web/src/domains/chat/components/slack-channel-footer.test.tsx +++ b/apps/web/src/domains/chat/components/slack-channel-footer.test.tsx @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; -import { client } from "@/domains/chat/api/client"; +import { client } from "@/generated/api/client.gen"; import { SlackChannelFooter } from "@/domains/chat/components/slack-channel-footer"; describe("SlackChannelFooter lazy channel name resolution", () => { diff --git a/apps/web/src/domains/chat/hooks/stream-message-updaters.ts b/apps/web/src/domains/chat/hooks/stream-message-updaters.ts index d27cb547af3..80bae6416b0 100644 --- a/apps/web/src/domains/chat/hooks/stream-message-updaters.ts +++ b/apps/web/src/domains/chat/hooks/stream-message-updaters.ts @@ -11,7 +11,7 @@ import type { DisplayMessage } from "@/domains/chat/utils/reconcile"; import type { Surface } from "@/domains/chat/types/types"; -import { toDisplayAttachments } from "@/domains/chat/api/event-parser"; +import { toDisplayAttachments } from "@/lib/streaming/event-parser"; import type { AllowlistOption, DirectoryScopeOption, ScopeOption } from "@/types/interaction-ui-types"; import type { ChatMessageToolCall } from "@/domains/chat/api/event-types"; import type { MessageCompleteEvent } from "@vellumai/assistant-api"; diff --git a/apps/web/src/domains/chat/hooks/use-conversation-history.ts b/apps/web/src/domains/chat/hooks/use-conversation-history.ts index 8861cbd4c65..fd320c813e3 100644 --- a/apps/web/src/domains/chat/hooks/use-conversation-history.ts +++ b/apps/web/src/domains/chat/hooks/use-conversation-history.ts @@ -33,10 +33,8 @@ import { reconcileDisplayMessagesWithLatestHistory, } from "@/domains/chat/utils/reconcile"; import { filterDismissedSurfaces } from "@/domains/chat/utils/dismissed-surfaces-storage"; -import { - recordChatDiagnostic, - summarizeDisplayMessages, -} from "@/domains/chat/utils/diagnostics"; +import { recordDiagnostic } from "@/lib/diagnostics"; +import { summarizeDisplayMessages } from "@/domains/chat/utils/diagnostics"; import type { TranscriptPaginationState } from "@/domains/chat/transcript/types"; import type { ContextWindowUsage } from "@/domains/chat/components/context-window-indicator"; import { useConversationStore } from "@/stores/conversation-store"; @@ -179,7 +177,7 @@ export function useConversationHistory({ const isFreshSwitch = switchResetRef.current; switchResetRef.current = false; - recordChatDiagnostic("history_tq_data_apply", { + recordDiagnostic("history_tq_data_apply", { assistantId, conversationId: activeConversationId, isFreshSwitch, @@ -193,7 +191,7 @@ export function useConversationHistory({ dismissedSurfaceIdsRef.current, ); - recordChatDiagnostic("history_tq_set_messages", { + recordDiagnostic("history_tq_set_messages", { assistantId, conversationId: activeConversationId, isFreshSwitch, @@ -256,7 +254,7 @@ export function useConversationHistory({ } } } else { - recordChatDiagnostic("history_tq_empty", { + recordDiagnostic("history_tq_empty", { assistantId, conversationId: activeConversationId, }); diff --git a/apps/web/src/domains/chat/hooks/use-conversation-switch.ts b/apps/web/src/domains/chat/hooks/use-conversation-switch.ts index 266f826e082..31d97f7599c 100644 --- a/apps/web/src/domains/chat/hooks/use-conversation-switch.ts +++ b/apps/web/src/domains/chat/hooks/use-conversation-switch.ts @@ -21,7 +21,7 @@ import { import { useTurnStore } from "@/domains/chat/turn-store"; import { useInteractionStore } from "@/domains/chat/interaction-store"; import { useConversationStore } from "@/stores/conversation-store"; -import { recordChatDiagnostic } from "@/domains/chat/utils/diagnostics"; +import { recordDiagnostic } from "@/lib/diagnostics"; import { loadDismissedSurfaceIds } from "@/domains/chat/utils/dismissed-surfaces-storage"; import type { DisplayMessage } from "@/domains/chat/utils/reconcile"; import type { TranscriptPaginationState } from "@/domains/chat/transcript/types"; @@ -128,7 +128,7 @@ export function useConversationSwitch({ } previousConversationIdRef.current = activeConversationId; - recordChatDiagnostic("conversation_switch_reset", { + recordDiagnostic("conversation_switch_reset", { assistantId, conversationId: activeConversationId, outgoingConversationId: outgoingConversationId ?? null, diff --git a/apps/web/src/domains/chat/hooks/use-empty-state-greeting.ts b/apps/web/src/domains/chat/hooks/use-empty-state-greeting.ts index 24bfb5b7b8d..18f1186755a 100644 --- a/apps/web/src/domains/chat/hooks/use-empty-state-greeting.ts +++ b/apps/web/src/domains/chat/hooks/use-empty-state-greeting.ts @@ -12,11 +12,8 @@ import { useQuery } from "@tanstack/react-query"; -import { - client, - assertHasResponse, - SDK_BASE_OPTIONS, -} from "@/domains/chat/api/client"; +import { client } from "@/generated/api/client.gen"; +import { assertHasResponse, SDK_BASE_OPTIONS } from "@/utils/api-errors"; import { DEFAULT_EMPTY_STATE_GREETING } from "@/domains/chat/utils/empty-state-constants"; const STALE_TIME_MS = 5 * 60 * 1000; diff --git a/apps/web/src/domains/chat/hooks/use-event-stream-conversation-filter.test.tsx b/apps/web/src/domains/chat/hooks/use-event-stream-conversation-filter.test.tsx index 2a6fbbda586..b8a61fbcb8e 100644 --- a/apps/web/src/domains/chat/hooks/use-event-stream-conversation-filter.test.tsx +++ b/apps/web/src/domains/chat/hooks/use-event-stream-conversation-filter.test.tsx @@ -3,7 +3,7 @@ import { cleanup, renderHook } from "@testing-library/react"; import { useRef, type MutableRefObject } from "react"; import type { AssistantEvent } from "@/types/event-types"; -import type { ChatEventStream } from "@/domains/chat/api/stream"; +import type { ChatEventStream } from "@/lib/streaming/stream-transport"; import { __resetEventBusForTesting, useEventBusStore, diff --git a/apps/web/src/domains/chat/hooks/use-event-stream-rapid-switch.test.tsx b/apps/web/src/domains/chat/hooks/use-event-stream-rapid-switch.test.tsx index f87c72d9296..67302c30217 100644 --- a/apps/web/src/domains/chat/hooks/use-event-stream-rapid-switch.test.tsx +++ b/apps/web/src/domains/chat/hooks/use-event-stream-rapid-switch.test.tsx @@ -4,7 +4,7 @@ import { act } from "react"; import { useRef, type MutableRefObject } from "react"; import type { AssistantEvent } from "@/types/event-types"; -import type { ChatEventStream } from "@/domains/chat/api/stream"; +import type { ChatEventStream } from "@/lib/streaming/stream-transport"; import { __resetEventBusForTesting, useEventBusStore, diff --git a/apps/web/src/domains/chat/hooks/use-event-stream-resume-reconcile.test.tsx b/apps/web/src/domains/chat/hooks/use-event-stream-resume-reconcile.test.tsx index 40f17e0dd10..7f5f3321f88 100644 --- a/apps/web/src/domains/chat/hooks/use-event-stream-resume-reconcile.test.tsx +++ b/apps/web/src/domains/chat/hooks/use-event-stream-resume-reconcile.test.tsx @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { cleanup, renderHook } from "@testing-library/react"; import { useRef, type MutableRefObject } from "react"; -import type { ChatEventStream } from "@/domains/chat/api/stream"; +import type { ChatEventStream } from "@/lib/streaming/stream-transport"; import { __resetEventBusForTesting, useEventBusStore, diff --git a/apps/web/src/domains/chat/hooks/use-event-stream.ts b/apps/web/src/domains/chat/hooks/use-event-stream.ts index 3db521e5452..1cb56e18370 100644 --- a/apps/web/src/domains/chat/hooks/use-event-stream.ts +++ b/apps/web/src/domains/chat/hooks/use-event-stream.ts @@ -37,9 +37,9 @@ import type { AssistantEvent } from "@/types/event-types"; import { isConversationScopedStreamEvent } from "@/domains/chat/utils/chat"; import { bucketMessagesAdded, - recordChatDiagnostic, + recordDiagnostic, resolvePlatformTag, -} from "@/domains/chat/utils/diagnostics"; +} from "@/lib/diagnostics"; import type { ActiveConversationMessagesRefreshResult, WebSyncRouter, @@ -51,7 +51,7 @@ import { isSending, useTurnStore, } from "@/domains/chat/turn-store"; -import type { ChatEventStream } from "@/domains/chat/api/stream"; +import type { ChatEventStream } from "@/lib/streaming/stream-transport"; import { useEventBusStore } from "@/stores/event-bus-store"; import type { UseAssistantReachabilityResult } from "@/assistant/use-assistant-reachability"; @@ -245,7 +245,7 @@ export function useEventStream({ eventConversationId === undefined || eventConversationId !== activeConversationIdLatestRef.current ) { - recordChatDiagnostic("sse_event_wrong_conversation_filtered", { + recordDiagnostic("sse_event_wrong_conversation_filtered", { eventConversationId, activeConversationId: activeConversationIdLatestRef.current, eventType: event.type, @@ -303,7 +303,7 @@ export function useEventStream({ .subscribe("sse.opened", ({ assistantId: openedFor, cause }) => { if (openedFor !== capturedAssistantId) return; const epoch = ++streamEpochRef.current; - recordChatDiagnostic("sse_stream_opened", { + recordDiagnostic("sse_stream_opened", { assistantId: capturedAssistantId, conversationId: capturedConversationId, epoch, @@ -328,7 +328,7 @@ export function useEventStream({ // reconcile. if (cause === "watchdog" || cause === "error") { void (async () => { - recordChatDiagnostic("sse_stream_reconnect", { + recordDiagnostic("sse_stream_reconnect", { assistantId: capturedAssistantId, conversationId: capturedConversationId, epoch, @@ -349,7 +349,7 @@ export function useEventStream({ // would cancel the newer loop and then exit as stale, // leaving no active loop running. if (epoch !== streamEpochRef.current) { - recordChatDiagnostic("sse_post_reconnect_stale", { + recordDiagnostic("sse_post_reconnect_stale", { assistantId: capturedAssistantId, conversationId: capturedConversationId, epoch, @@ -361,7 +361,7 @@ export function useEventStream({ startReconciliationLoopRef.current(epoch); if (cause !== "watchdog") return; const latencyMs = Date.now() - startedAt; - recordChatDiagnostic("sse_post_watchdog_reconcile_result", { + recordDiagnostic("sse_post_watchdog_reconcile_result", { assistantId: capturedAssistantId, conversationId: capturedConversationId, epoch, @@ -442,7 +442,7 @@ export function useEventStream({ .getState() .subscribe("sse.closed", ({ reason }) => { const hadActiveTurn = isSending(useTurnStore.getState()); - recordChatDiagnostic("sse_stream_error", { + recordDiagnostic("sse_stream_error", { assistantId: capturedAssistantId, conversationId: capturedConversationId, epoch: streamEpochRef.current, diff --git a/apps/web/src/domains/chat/hooks/use-message-reconciliation.ts b/apps/web/src/domains/chat/hooks/use-message-reconciliation.ts index 2f10d758a5f..ea5c0ed6271 100644 --- a/apps/web/src/domains/chat/hooks/use-message-reconciliation.ts +++ b/apps/web/src/domains/chat/hooks/use-message-reconciliation.ts @@ -2,10 +2,8 @@ import { type Dispatch, type RefObject, type SetStateAction, useCallback, useRef import * as Sentry from "@sentry/browser"; +import { bucketMessagesAdded, recordDiagnostic, resolvePlatformTag } from "@/lib/diagnostics"; import { - bucketMessagesAdded, - recordChatDiagnostic, - resolvePlatformTag, summarizeDisplayMessages, summarizeRuntimeMessages, } from "@/domains/chat/utils/diagnostics"; @@ -127,7 +125,7 @@ export function useMessageReconciliation({ if (reconcileTimerRef.current) { clearTimeout(reconcileTimerRef.current); reconcileTimerRef.current = null; - recordChatDiagnostic("reconciliation_loop_cancelled", {}); + recordDiagnostic("reconciliation_loop_cancelled", {}); } }, []); @@ -140,7 +138,7 @@ export function useMessageReconciliation({ messagesAdded: number; } => { if (serverMessages.length === 0) { - recordChatDiagnostic("reconciliation_skipped_empty_server", {}); + recordDiagnostic("reconciliation_skipped_empty_server", {}); return { changed: false, assistantProgress: false, messagesAdded: 0 }; } @@ -165,7 +163,7 @@ export function useMessageReconciliation({ localAfter = summarizeDisplayMessages(next); return next; }); - recordChatDiagnostic("reconciliation_applied", { + recordDiagnostic("reconciliation_applied", { changed, assistantProgress, messagesAdded, @@ -313,7 +311,7 @@ export function useMessageReconciliation({ const startReconciliationLoop = useCallback( (epoch: number) => { cancelReconciliation(); - recordChatDiagnostic("reconciliation_loop_start", { epoch }); + recordDiagnostic("reconciliation_loop_start", { epoch }); const startTime = Date.now(); let stableCount = 0; @@ -322,7 +320,7 @@ export function useMessageReconciliation({ reconcileTimerRef.current = null; const ctx = streamContextRef.current; if (!ctx || epoch !== streamEpochRef.current) { - recordChatDiagnostic("reconciliation_loop_finish", { + recordDiagnostic("reconciliation_loop_finish", { epoch, reason: !ctx ? "no_context" : "epoch_changed", stableCount, @@ -331,7 +329,7 @@ export function useMessageReconciliation({ return; } if (Date.now() - startTime >= RECONCILE_MAX_MS) { - recordChatDiagnostic("reconciliation_loop_finish", { + recordDiagnostic("reconciliation_loop_finish", { epoch, reason: "max_duration", stableCount, @@ -344,7 +342,7 @@ export function useMessageReconciliation({ fetchConversationMessages(ctx.assistantId, ctx.conversationId) .then((serverMessages) => { if (epoch !== streamEpochRef.current) return; - recordChatDiagnostic("reconciliation_fetch", { + recordDiagnostic("reconciliation_fetch", { assistantId: ctx.assistantId, conversationId: ctx.conversationId, epoch, @@ -364,7 +362,7 @@ export function useMessageReconciliation({ } if (stableCount >= RECONCILE_STABLE_COUNT) { - recordChatDiagnostic("reconciliation_loop_finish", { + recordDiagnostic("reconciliation_loop_finish", { epoch, reason: "stable", stableCount, @@ -373,7 +371,7 @@ export function useMessageReconciliation({ return; } if (epoch !== streamEpochRef.current) { - recordChatDiagnostic("reconciliation_loop_finish", { + recordDiagnostic("reconciliation_loop_finish", { epoch, reason: "epoch_changed_post_fetch", stableCount, @@ -385,7 +383,7 @@ export function useMessageReconciliation({ }) .catch(() => { if (epoch !== streamEpochRef.current) { - recordChatDiagnostic("reconciliation_loop_finish", { + recordDiagnostic("reconciliation_loop_finish", { epoch, reason: "epoch_changed_post_error", stableCount, @@ -393,7 +391,7 @@ export function useMessageReconciliation({ }); return; } - recordChatDiagnostic("reconciliation_fetch_error", { + recordDiagnostic("reconciliation_fetch_error", { assistantId: ctx.assistantId, conversationId: ctx.conversationId, epoch, @@ -439,7 +437,7 @@ export function useMessageReconciliation({ // If the epoch changed during the fetch (e.g. page went hidden // and back), this reconciliation is stale — bail out. if (streamEpochRef.current !== snapshotEpoch) return empty; - recordChatDiagnostic("reconciliation_active_fetch", { + recordDiagnostic("reconciliation_active_fetch", { assistantId: ctx.assistantId, conversationId: ctx.conversationId, epoch: snapshotEpoch, @@ -453,7 +451,7 @@ export function useMessageReconciliation({ } catch { // Non-fatal: a fetch failure doesn't prove the turn completed. // The .finally() nonce bump reopens SSE to deliver terminal events. - recordChatDiagnostic("reconciliation_active_fetch_error", { + recordDiagnostic("reconciliation_active_fetch_error", { assistantId: ctx.assistantId, conversationId: ctx.conversationId, epoch: snapshotEpoch, diff --git a/apps/web/src/domains/chat/hooks/use-send-message.ts b/apps/web/src/domains/chat/hooks/use-send-message.ts index 2d382a35c8e..428a857cddf 100644 --- a/apps/web/src/domains/chat/hooks/use-send-message.ts +++ b/apps/web/src/domains/chat/hooks/use-send-message.ts @@ -30,7 +30,7 @@ import { import { isAsyncChatScopeCurrent } from "@/domains/chat/utils/conversation-scope"; import { resolveEditChatDraftConversationId } from "@/domains/chat/utils/edit-chat-session"; import { type DiskPressureChatBlockReason, getDiskPressureChatBlockMessage } from "@/assistant/disk-pressure"; -import { recordChatDiagnostic } from "@/domains/chat/utils/diagnostics"; +import { recordDiagnostic } from "@/lib/diagnostics"; import { saveDismissedSurfaceIds } from "@/domains/chat/utils/dismissed-surfaces-storage"; import { isSending, useTurnStore } from "@/domains/chat/turn-store"; import { endTurn } from "@/domains/chat/turn-coordinator"; @@ -66,7 +66,7 @@ import { conversationsByIdCancelPost } from "@/generated/daemon/sdk.gen"; import type { Conversation } from "@/types/conversation-types"; import { getPendingInteractions } from "@/domains/chat/api/interactions"; import { type RuntimeMessage, fetchConversationMessages, postChatMessage, pollForResponse } from "@/domains/chat/api/messages"; -import type { ChatEventStream } from "@/domains/chat/api/stream"; +import type { ChatEventStream } from "@/lib/streaming/stream-transport"; import { supportsServerMintedConversation } from "@/lib/backwards-compat/server-minted-conversation"; // Re-export pure utilities so existing consumers don't break. @@ -309,7 +309,7 @@ export function useSendMessage({ } if (!postResult.ok) { if (!isCurrentSendScope()) { - recordChatDiagnostic("send_error_ignored_inactive_conversation", { + recordDiagnostic("send_error_ignored_inactive_conversation", { assistantId: requestAssistantId, conversationId: requestConversationId, activeAssistantId: assistantIdRef.current, @@ -351,7 +351,7 @@ export function useSendMessage({ const effectiveConversationId = postResult.conversationId; if (!isCurrentSendScope(effectiveConversationId)) { - recordChatDiagnostic("send_result_ignored_inactive_conversation", { + recordDiagnostic("send_result_ignored_inactive_conversation", { assistantId: postResult.assistantId, conversationId: requestConversationId, resolvedConversationId: effectiveConversationId, @@ -392,7 +392,7 @@ export function useSendMessage({ pollForResponse(postResult.assistantId, postResult.messageId, effectiveConversationId) .then(async (reply) => { if (!isCurrentSendScope(effectiveConversationId)) { - recordChatDiagnostic("poll_response_ignored_inactive_conversation", { + recordDiagnostic("poll_response_ignored_inactive_conversation", { assistantId: postResult.assistantId, conversationId: requestConversationId, resolvedConversationId: effectiveConversationId, diff --git a/apps/web/src/domains/chat/hooks/use-stream-event-handler.ts b/apps/web/src/domains/chat/hooks/use-stream-event-handler.ts index 80eb17ffd5c..c43a214e484 100644 --- a/apps/web/src/domains/chat/hooks/use-stream-event-handler.ts +++ b/apps/web/src/domains/chat/hooks/use-stream-event-handler.ts @@ -16,10 +16,7 @@ import { useTurnStore } from "@/domains/chat/turn-store"; import { endTurn } from "@/domains/chat/turn-coordinator"; import { useViewerStore } from "@/stores/viewer-store"; import type { DiskPressureStatusEventPayload } from "@/assistant/use-disk-pressure-monitor"; -import { - recordChatDiagnostic, - summarizeAssistantEvent, -} from "@/domains/chat/utils/diagnostics"; +import { recordDiagnostic, summarizeAssistantEvent } from "@/lib/diagnostics"; import { isConversationScopedStreamEvent } from "@/domains/chat/utils/chat"; import { handleHomeFeedUpdated, @@ -93,7 +90,7 @@ export type { import type { ChatError } from "@/domains/chat/types"; import type { AssistantEvent, AssistantSyncChangedEvent } from "@/types/event-types"; -import type { ChatEventStream } from "@/domains/chat/api/stream"; +import type { ChatEventStream } from "@/lib/streaming/stream-transport"; // --------------------------------------------------------------------------- // Params & return types @@ -231,7 +228,7 @@ export function useStreamEventHandler( // Discard events from stale/previous streams const eventSummary = summarizeAssistantEvent(event); if (epoch !== streamEpochRef.current) { - recordChatDiagnostic("sse_event_stale", { + recordDiagnostic("sse_event_stale", { epoch, currentEpoch: streamEpochRef.current, activeConversationId: useConversationStore.getState().activeConversationId, @@ -250,7 +247,7 @@ export function useStreamEventHandler( // through unconditionally. if (isConversationScopedStreamEvent(event)) { if (!event.conversationId || !streamConversationId) { - recordChatDiagnostic("sse_event_wrong_conversation", { + recordDiagnostic("sse_event_wrong_conversation", { epoch, activeConversationId: useConversationStore.getState().activeConversationId, streamContext: streamContextRef.current, @@ -260,7 +257,7 @@ export function useStreamEventHandler( return; } if (event.conversationId !== streamConversationId) { - recordChatDiagnostic("sse_event_wrong_conversation", { + recordDiagnostic("sse_event_wrong_conversation", { epoch, activeConversationId: useConversationStore.getState().activeConversationId, streamContext: streamContextRef.current, @@ -278,7 +275,7 @@ export function useStreamEventHandler( event.type !== "assistant_text_delta" || !tailIsStreamingAssistant(messagesRef.current) ) { - recordChatDiagnostic( + recordDiagnostic( event.type === "assistant_text_delta" ? "sse_assistant_text_delta_start" : "sse_event", diff --git a/apps/web/src/domains/chat/inspector/compaction-trail-fetch.test.ts b/apps/web/src/domains/chat/inspector/compaction-trail-fetch.test.ts index e322c86584c..2beaa092c47 100644 --- a/apps/web/src/domains/chat/inspector/compaction-trail-fetch.test.ts +++ b/apps/web/src/domains/chat/inspector/compaction-trail-fetch.test.ts @@ -26,7 +26,7 @@ import { test, } from "bun:test"; -import { client } from "@/domains/chat/api/client"; +import { client } from "@/generated/api/client.gen"; import { CompactionTrailRequestError, diff --git a/apps/web/src/domains/chat/inspector/compaction-trail-fetch.ts b/apps/web/src/domains/chat/inspector/compaction-trail-fetch.ts index 17352c13b1f..d88c80e4fc7 100644 --- a/apps/web/src/domains/chat/inspector/compaction-trail-fetch.ts +++ b/apps/web/src/domains/chat/inspector/compaction-trail-fetch.ts @@ -19,8 +19,8 @@ * `archiveConversation`). */ -import { client, SDK_BASE_OPTIONS } from "@/domains/chat/api/client"; -import { assertHasResponse } from "@/utils/api-errors"; +import { client } from "@/generated/api/client.gen"; +import { assertHasResponse, SDK_BASE_OPTIONS } from "@/utils/api-errors"; import type { CompactionTrailResponse } from "./compaction-trail-types"; diff --git a/apps/web/src/domains/chat/inspector/inspector-api.ts b/apps/web/src/domains/chat/inspector/inspector-api.ts index 018b1d5db07..17716042457 100644 --- a/apps/web/src/domains/chat/inspector/inspector-api.ts +++ b/apps/web/src/domains/chat/inspector/inspector-api.ts @@ -1,10 +1,7 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; -import { - assertHasResponse, - client, - extractErrorMessage, -} from "@/domains/chat/api/client"; +import { client } from "@/generated/api/client.gen"; +import { assertHasResponse, extractErrorMessage } from "@/utils/api-errors"; import { fetchConversationMessages, type RuntimeMessage, diff --git a/apps/web/src/domains/chat/types/types.ts b/apps/web/src/domains/chat/types/types.ts index d6b1e3fa6fb..6beea149534 100644 --- a/apps/web/src/domains/chat/types/types.ts +++ b/apps/web/src/domains/chat/types/types.ts @@ -1,25 +1,12 @@ /** * Shared types for the chat/surface system. - * Lives here (rather than in a Next.js route file) so both the main app - * and the CDN build can import them. */ import type { ChatMessageToolCall } from "@/domains/chat/api/event-types"; +import type { DisplayAttachment } from "@/types/attachment-types"; import type { SlackMessageLink } from "@/utils/slack-message-link"; -/** Display metadata for a file attachment (user-uploaded or assistant-generated), - * used to render the chip inside a message bubble. For live sessions, populated - * from SSE event data via `toDisplayAttachments`. For history reload, populated - * from the daemon's structured attachment metadata (real UUIDs that resolve - * against the content endpoint) or, as a fallback, reverse-parsed from - * `[File attachment] …` summary lines in the message text. */ -export interface DisplayAttachment { - id: string; - filename: string; - mimeType: string; - sizeBytes: number; - previewUrl: string | null; -} +export type { DisplayAttachment } from "@/types/attachment-types"; export type { SlackMessageLink } from "@/utils/slack-message-link"; export { parseSlackMessageLink, getSlackLinkUrl } from "@/utils/slack-message-link"; diff --git a/apps/web/src/domains/chat/utils/attachment-mapping.ts b/apps/web/src/domains/chat/utils/attachment-mapping.ts index 958cde5b47d..68b246a20dd 100644 --- a/apps/web/src/domains/chat/utils/attachment-mapping.ts +++ b/apps/web/src/domains/chat/utils/attachment-mapping.ts @@ -1,4 +1,4 @@ -import type { DisplayAttachment } from "@/domains/chat/types/types"; +import type { DisplayAttachment } from "@/types/attachment-types"; import type { RuntimeAttachment } from "@/domains/chat/api/messages"; /** diff --git a/apps/web/src/domains/chat/utils/debug-api.test.ts b/apps/web/src/domains/chat/utils/debug-api.test.ts index 6d2d4099965..40d35246aa5 100644 --- a/apps/web/src/domains/chat/utils/debug-api.test.ts +++ b/apps/web/src/domains/chat/utils/debug-api.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from "bun:test"; import type { MutableRefObject } from "react"; -import type { ChatEventStream } from "@/domains/chat/api/stream"; +import type { ChatEventStream } from "@/lib/streaming/stream-transport"; import type { TranscriptHandle } from "@/domains/chat/transcript/use-transcript-scroll"; import type { TranscriptItem } from "@/domains/chat/transcript/types"; import type { DisplayMessage } from "@/domains/chat/utils/reconcile"; diff --git a/apps/web/src/domains/chat/utils/debug-api.ts b/apps/web/src/domains/chat/utils/debug-api.ts index a01465aa5f8..d026e6e0158 100644 --- a/apps/web/src/domains/chat/utils/debug-api.ts +++ b/apps/web/src/domains/chat/utils/debug-api.ts @@ -33,14 +33,14 @@ import { fetchConversationMessages as defaultFetchConversationMessages, type RuntimeMessage, } from "@/domains/chat/api/messages"; -import type { ChatEventStream } from "@/domains/chat/api/stream"; +import type { ChatEventStream } from "@/lib/streaming/stream-transport"; import type { PendingConfirmationState, PendingContactRequestState, PendingQuestionState, PendingSecretState, } from "@/types/interaction-ui-types"; -import { recordChatDiagnostic } from "@/domains/chat/utils/diagnostics"; +import { recordDiagnostic } from "@/lib/diagnostics"; import type { DisplayMessage } from "@/domains/chat/utils/reconcile"; import type { ReconcileActiveConversationResult } from "@/domains/chat/hooks/use-message-reconciliation"; import { setImpersonatedAssistantVersion } from "@/lib/backwards-compat/impersonate-version-flag"; @@ -495,7 +495,7 @@ export function createChatDebugApi(refs: ChatDebugRefs): ChatDebugApi { // true. If this ever drifts we want the test suite (and DevTools users) to // notice immediately rather than chasing a confusing report. if (visible !== (failingConditions.length === 0)) { - recordChatDiagnostic("debug_thinking_indicator_drift", { + recordDiagnostic("debug_thinking_indicator_drift", { visible, failingConditionCount: failingConditions.length, }); @@ -538,12 +538,12 @@ export function createChatDebugApi(refs: ChatDebugRefs): ChatDebugApi { } async function forceReconcile(): Promise { - recordChatDiagnostic("debug_force_reconcile_start", { + recordDiagnostic("debug_force_reconcile_start", { activeConversationId: useConversationStore.getState().activeConversationId, assistantId: refs.getAssistantId(), }); const result = await refs.reconcileActiveConversation(); - recordChatDiagnostic("debug_force_reconcile_result", { + recordDiagnostic("debug_force_reconcile_result", { activeConversationId: useConversationStore.getState().activeConversationId, changed: result.changed, messagesAdded: result.messagesAdded, diff --git a/apps/web/src/domains/chat/utils/diagnostics.ts b/apps/web/src/domains/chat/utils/diagnostics.ts index 72e28690af3..2eb0d4a263d 100644 --- a/apps/web/src/domains/chat/utils/diagnostics.ts +++ b/apps/web/src/domains/chat/utils/diagnostics.ts @@ -1,142 +1,14 @@ -import { Capacitor } from "@capacitor/core"; +/** + * Chat-domain diagnostic summarization helpers. + * + * Compact summaries of chat-specific types (DisplayMessage, RuntimeMessage) + * for the diagnostics ring buffer. Generic recording infrastructure lives + * in `@/lib/diagnostics`. + */ import type { DisplayMessage } from "@/domains/chat/utils/reconcile"; -import type { AssistantEvent } from "@/types/event-types"; import type { RuntimeMessage } from "@/domains/chat/api/messages"; - -const MAX_EVENTS = 200; -const STORAGE_KEY = "vellum:chat-diagnostics:v1"; - -export interface ChatDiagnosticsEvent { - ts: string; - kind: string; - details: Record; -} - -let loaded = false; -let events: ChatDiagnosticsEvent[] = []; - -function getSessionStorage(): Storage | null { - if (typeof window === "undefined") return null; - try { - return window.sessionStorage; - } catch { - return null; - } -} - -function loadEvents(): void { - if (loaded) return; - loaded = true; - const storage = getSessionStorage(); - if (!storage) return; - try { - const raw = storage.getItem(STORAGE_KEY); - if (!raw) return; - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return; - events = parsed - .filter( - (event): event is ChatDiagnosticsEvent => - event != null && - typeof event === "object" && - typeof event.ts === "string" && - typeof event.kind === "string" && - event.details != null && - typeof event.details === "object" && - !Array.isArray(event.details), - ) - .slice(-MAX_EVENTS); - } catch { - events = []; - } -} - -function saveEvents(): void { - const storage = getSessionStorage(); - if (!storage) return; - try { - storage.setItem(STORAGE_KEY, JSON.stringify(events)); - } catch { - // Diagnostics are best-effort and must never affect chat behavior. - } -} - -export function resolvePlatformTag(): string { - // Defensive lookup: diagnostics must never throw — if the - // Capacitor runtime is not initialized (SSR, tests, certain - // bundler configs where the named import resolves to a stub), - // fall back to "web" so support snapshots still get split by - // surface where the runtime is present and degrade gracefully - // where it is not. - try { - const platform = ( - Capacitor as unknown as { getPlatform?: () => string } - ).getPlatform?.(); - if (typeof platform === "string" && platform.length > 0) { - return platform; - } - } catch { - // fall through - } - return "web"; -} - -export function recordChatDiagnostic( - kind: string, - details: Record = {}, -): void { - loadEvents(); - // Tag every event with the runtime platform so support snapshots - // can be split by surface (Capacitor iOS / Android / web) without - // requiring per-call-site plumbing. Matches the OpenTelemetry - // "resource attribute" / Sentry "global tag" convention of - // injecting ambient context once at the SDK boundary rather than - // duplicating it at every call site. Call-site keys win on - // collision so a future event can override if needed. - // https://opentelemetry.io/docs/specs/otel/resource/sdk/ - events.push({ - ts: new Date().toISOString(), - kind, - details: { platform: resolvePlatformTag(), ...details }, - }); - if (events.length > MAX_EVENTS) { - events = events.slice(-MAX_EVENTS); - } - saveEvents(); -} - -// Sentry tags must stay low-cardinality so the values are aggregable -// in Discover and don't blow up the project's tag budget; bucketing -// the raw count to a fixed set of bands trades resolution for -// queryability. Bands are chosen so 0 (no rescue) and 1 (single -// missed message — the LUM-1431 shape) are distinguishable, and -// larger rescues collapse into coarser buckets where the exact -// count matters less than "this happened." -// https://docs.sentry.io/concepts/key-terms/key-terms/#tags -export function bucketMessagesAdded(count: number): string { - if (!Number.isFinite(count) || count <= 0) return "0"; - if (count === 1) return "1"; - if (count <= 5) return "2-5"; - return "6+"; -} - -export function getChatDiagnosticsEvents(): ChatDiagnosticsEvent[] { - loadEvents(); - return events.map((event) => ({ - ts: event.ts, - kind: event.kind, - details: { ...event.details }, - })); -} - -function roleCounts(messages: Array<{ role: string }>): Record { - const counts: Record = {}; - for (const message of messages) { - counts[message.role] = (counts[message.role] ?? 0) + 1; - } - return counts; -} +import { roleCounts } from "@/lib/diagnostics"; export function summarizeDisplayMessage(message: DisplayMessage): Record { return { @@ -197,138 +69,3 @@ export function summarizeRuntimeMessages( tail: messages.slice(-tailCount).map(summarizeRuntimeMessage), }; } - -function copyStringField( - summary: Record, - record: Record, - key: string, -): void { - if (typeof record[key] === "string") { - summary[key] = record[key]; - } -} - -function copyNumberField( - summary: Record, - record: Record, - key: string, -): void { - if (typeof record[key] === "number" && Number.isFinite(record[key])) { - summary[key] = record[key]; - } -} - -function copyBooleanField( - summary: Record, - record: Record, - key: string, -): void { - if (typeof record[key] === "boolean") { - summary[key] = record[key]; - } -} - -function copyStringLengthField( - summary: Record, - record: Record, - key: string, -): void { - if (typeof record[key] === "string") { - summary[`${key}Length`] = record[key].length; - } -} - -export function summarizeAssistantEvent( - event: AssistantEvent, -): Record { - const record = event as unknown as Record; - const summary: Record = { type: event.type }; - - for (const key of [ - "messageId", - "requestId", - "surfaceId", - "surfaceType", - "toolUseId", - "conversationId", - "deliveryId", - "code", - "toolName", - "errorCategory", - "rawType", - "tab", - "sourceEventName", - ]) { - copyStringField(summary, record, key); - } - for (const key of [ - "position", - "inputTokens", - "outputTokens", - "cachedInputTokens", - "cacheCreationInputTokens", - "contextWindowTokens", - "contextWindowMaxTokens", - "openUntil", - ]) { - copyNumberField(summary, record, key); - } - for (const key of ["isError", "retryable", "runStillActive"]) { - copyBooleanField(summary, record, key); - } - for (const key of [ - "text", - "content", - "message", - "userMessage", - "debugDetails", - "title", - "body", - "summary", - "result", - "url", - ]) { - copyStringLengthField(summary, record, key); - } - - if (typeof record.url === "string") { - try { - summary.urlHost = new URL(record.url).host; - } catch { - summary.urlHost = null; - } - } - if (Array.isArray(record.attachments)) { - summary.attachmentCount = record.attachments.length; - } - if (Array.isArray(record.actions)) { - summary.actionCount = record.actions.length; - } - if ( - record.data != null && - typeof record.data === "object" && - !Array.isArray(record.data) - ) { - summary.dataKeys = Object.keys(record.data).length; - } - if ( - record.input != null && - typeof record.input === "object" && - !Array.isArray(record.input) - ) { - summary.inputKeys = Object.keys(record.input).length; - } - - return summary; -} - -export function buildChatDiagnosticsSnapshot( - currentState: Record | null, -): Record { - return { - schemaVersion: 1, - collectedAt: new Date().toISOString(), - currentState, - events: getChatDiagnosticsEvents(), - }; -} diff --git a/apps/web/src/domains/chat/utils/parse-attachment-summaries.ts b/apps/web/src/domains/chat/utils/parse-attachment-summaries.ts index 704666e6d1a..b6cdcd793e4 100644 --- a/apps/web/src/domains/chat/utils/parse-attachment-summaries.ts +++ b/apps/web/src/domains/chat/utils/parse-attachment-summaries.ts @@ -1,4 +1,4 @@ -import type { DisplayAttachment } from "@/domains/chat/types/types"; +import type { DisplayAttachment } from "@/types/attachment-types"; // Single attachment summary line emitted by the runtime daemon when echoing // history (see vellum-assistant `daemon/handlers/shared.ts` diff --git a/apps/web/src/domains/chat/utils/stream-handlers/message-handlers.ts b/apps/web/src/domains/chat/utils/stream-handlers/message-handlers.ts index f3f9f7009f8..eab40cef3f3 100644 --- a/apps/web/src/domains/chat/utils/stream-handlers/message-handlers.ts +++ b/apps/web/src/domains/chat/utils/stream-handlers/message-handlers.ts @@ -1,4 +1,4 @@ -import { recordChatDiagnostic } from "@/domains/chat/utils/diagnostics"; +import { recordDiagnostic } from "@/lib/diagnostics"; import { appendTextDelta, finalizeMessageComplete, @@ -46,7 +46,7 @@ export function handleAssistantActivityState( const lastSeen = ctx.lastActivityVersionRef.current.get(convId) ?? 0; if (event.activityVersion <= lastSeen) { - recordChatDiagnostic("sse_activity_state_version_skipped", { + recordDiagnostic("sse_activity_state_version_skipped", { convId, phase: event.phase, eventVersion: event.activityVersion, @@ -59,7 +59,7 @@ export function handleAssistantActivityState( if (event.phase === "thinking") { ctx.turnActions.onActivityThinking(event.statusText); - recordChatDiagnostic("sse_activity_state_thinking_handled", { + recordDiagnostic("sse_activity_state_thinking_handled", { convId, reason: event.reason, activityVersion: event.activityVersion, @@ -68,7 +68,7 @@ export function handleAssistantActivityState( } if (event.phase !== "idle") { - recordChatDiagnostic("sse_activity_state_non_idle", { + recordDiagnostic("sse_activity_state_non_idle", { convId, phase: event.phase, reason: event.reason, @@ -80,7 +80,7 @@ export function handleAssistantActivityState( ctx.setMessages(finalizeOnIdle); const turnPhaseBefore = ctx.getTurnState().phase; ctx.endTurn({ conversationId: convId, reason: "complete" }); - recordChatDiagnostic("sse_activity_state_idle_handled", { + recordDiagnostic("sse_activity_state_idle_handled", { convId, reason: event.reason, activityVersion: event.activityVersion, @@ -118,7 +118,7 @@ export function handleMessageComplete( event.conversationId ?? ctx.streamContextRef.current?.conversationId; const turnPhaseBefore = ctx.getTurnState().phase; ctx.endTurn({ conversationId: convId, reason: "complete" }); - recordChatDiagnostic("sse_message_complete_handled", { + recordDiagnostic("sse_message_complete_handled", { convId, turnPhaseBefore, messageId: event.messageId, diff --git a/apps/web/src/domains/chat/utils/stream-handlers/types.ts b/apps/web/src/domains/chat/utils/stream-handlers/types.ts index 43db13cdcd0..a2ff2e11f8e 100644 --- a/apps/web/src/domains/chat/utils/stream-handlers/types.ts +++ b/apps/web/src/domains/chat/utils/stream-handlers/types.ts @@ -10,7 +10,7 @@ import type { TurnActions, TurnState } from "@/domains/chat/turn-store"; import type { EndTurnArgs } from "@/domains/chat/turn-coordinator"; import type { DiskPressureStatusEventPayload } from "@/assistant/use-disk-pressure-monitor"; import type { ChatError, PendingQuestionState } from "@/domains/chat/types"; -import type { ChatEventStream } from "@/domains/chat/api/stream"; +import type { ChatEventStream } from "@/lib/streaming/stream-transport"; export type { PendingQuestionState }; diff --git a/apps/web/src/domains/conversations/use-conversation-loader.ts b/apps/web/src/domains/conversations/use-conversation-loader.ts index 7fff4c2f910..345d48a619c 100644 --- a/apps/web/src/domains/conversations/use-conversation-loader.ts +++ b/apps/web/src/domains/conversations/use-conversation-loader.ts @@ -35,7 +35,7 @@ import type { AssistantStateKind, ChatError } from "@/domains/chat/types"; import { useConversationHistory } from "@/domains/chat/hooks/use-conversation-history"; import { useQueryClient } from "@tanstack/react-query"; -import { ApiError } from "@/domains/chat/api/client"; +import { ApiError } from "@/utils/api-errors"; import type { Conversation } from "@/types/conversation-types"; import { isBackgroundConversation } from "@/utils/conversation-predicates"; import { diff --git a/apps/web/src/hooks/use-event-bus-init.test.tsx b/apps/web/src/hooks/use-event-bus-init.test.tsx index f8f0ea0c44d..7603d4ff18f 100644 --- a/apps/web/src/hooks/use-event-bus-init.test.tsx +++ b/apps/web/src/hooks/use-event-bus-init.test.tsx @@ -34,7 +34,7 @@ const subscribeChatEventsMock = mock( }, ); -mock.module("@/domains/chat/api/stream", () => ({ +mock.module("@/lib/streaming/stream-transport", () => ({ subscribeChatEvents: subscribeChatEventsMock, })); diff --git a/apps/web/src/hooks/use-event-bus-init.ts b/apps/web/src/hooks/use-event-bus-init.ts index d8dffb5c09e..bcd4cd41ce0 100644 --- a/apps/web/src/hooks/use-event-bus-init.ts +++ b/apps/web/src/hooks/use-event-bus-init.ts @@ -23,8 +23,8 @@ import { useEffect } from "react"; import * as Sentry from "@sentry/browser"; import type { PluginListenerHandle } from "@capacitor/core"; -import { subscribeChatEvents } from "@/domains/chat/api/stream"; -import type { ChatEventStream } from "@/domains/chat/api/stream"; +import { subscribeChatEvents } from "@/lib/streaming/stream-transport"; +import type { ChatEventStream } from "@/lib/streaming/stream-transport"; import { useEventBusStore } from "@/stores/event-bus-store"; import { isNativePlatform } from "@/runtime/native-auth"; diff --git a/apps/web/src/domains/chat/utils/diagnostics-platform.test.ts b/apps/web/src/lib/diagnostics-platform.test.ts similarity index 56% rename from apps/web/src/domains/chat/utils/diagnostics-platform.test.ts rename to apps/web/src/lib/diagnostics-platform.test.ts index 92f308f973e..1c092a9dd69 100644 --- a/apps/web/src/domains/chat/utils/diagnostics-platform.test.ts +++ b/apps/web/src/lib/diagnostics-platform.test.ts @@ -8,34 +8,32 @@ mock.module("@capacitor/core", () => ({ }, })); -import { - getChatDiagnosticsEvents, - recordChatDiagnostic, -} from "@/domains/chat/utils/diagnostics"; +import { getDiagnosticsEvents, recordDiagnostic } from "@/lib/diagnostics"; // --------------------------------------------------------------------------- -// recordChatDiagnostic — centralized platform tag injection +// recordDiagnostic — centralized platform tag injection // -// The L2/L3 watchdog decision is platform-conditioned: LUM-1431 was -// iOS-only, so a platform breakdown of watchdog fires is the data we -// actually need. The diagnostics module injects `platform` once at the -// SDK boundary (per the OpenTelemetry resource-attribute convention -// — https://opentelemetry.io/docs/specs/otel/resource/sdk/) so every +// The idle-watchdog decision is platform-conditioned: iOS WKWebView +// silently stalls SSE connections, so a platform breakdown of watchdog +// fires is the data we actually need. The diagnostics module injects +// `platform` once at the SDK boundary (per the OpenTelemetry +// resource-attribute convention — +// https://opentelemetry.io/docs/specs/otel/resource/sdk/) so every // caller gets it for free without per-call-site plumbing. These tests // pin that contract and exercise the happy path under the mocked // Capacitor module rather than the diagnostics module's defensive // fallback. // --------------------------------------------------------------------------- -describe("recordChatDiagnostic platform tag", () => { +describe("recordDiagnostic platform tag", () => { test("injects platform from Capacitor.getPlatform on every recorded event", () => { mockedPlatform = "ios"; - const eventCountBefore = getChatDiagnosticsEvents().length; + const eventCountBefore = getDiagnosticsEvents().length; - recordChatDiagnostic("test_kind_a", { foo: "bar" }); - recordChatDiagnostic("test_kind_b", { baz: 1 }); + recordDiagnostic("test_kind_a", { foo: "bar" }); + recordDiagnostic("test_kind_b", { baz: 1 }); - const newEvents = getChatDiagnosticsEvents().slice(eventCountBefore); + const newEvents = getDiagnosticsEvents().slice(eventCountBefore); expect(newEvents).toHaveLength(2); expect(newEvents[0]!.kind).toBe("test_kind_a"); expect(newEvents[0]!.details.platform).toBe("ios"); @@ -48,26 +46,26 @@ describe("recordChatDiagnostic platform tag", () => { }); test("call-site keys win over the injected platform tag", () => { - const eventCountBefore = getChatDiagnosticsEvents().length; + const eventCountBefore = getDiagnosticsEvents().length; - recordChatDiagnostic("test_kind_override", { + recordDiagnostic("test_kind_override", { platform: "explicit-override", }); - const newEvents = getChatDiagnosticsEvents().slice(eventCountBefore); + const newEvents = getDiagnosticsEvents().slice(eventCountBefore); expect(newEvents).toHaveLength(1); expect(newEvents[0]!.details.platform).toBe("explicit-override"); }); test("injects different platform values when Capacitor reports different surfaces", () => { - const eventCountBefore = getChatDiagnosticsEvents().length; + const eventCountBefore = getDiagnosticsEvents().length; mockedPlatform = "android"; - recordChatDiagnostic("test_kind_android"); + recordDiagnostic("test_kind_android"); mockedPlatform = "web"; - recordChatDiagnostic("test_kind_web"); + recordDiagnostic("test_kind_web"); - const newEvents = getChatDiagnosticsEvents().slice(eventCountBefore); + const newEvents = getDiagnosticsEvents().slice(eventCountBefore); expect(newEvents).toHaveLength(2); expect(newEvents[0]!.details.platform).toBe("android"); expect(newEvents[1]!.details.platform).toBe("web"); diff --git a/apps/web/src/domains/chat/utils/diagnostics.test.ts b/apps/web/src/lib/diagnostics.test.ts similarity index 88% rename from apps/web/src/domains/chat/utils/diagnostics.test.ts rename to apps/web/src/lib/diagnostics.test.ts index e693d32c79c..c3c0a15d60b 100644 --- a/apps/web/src/domains/chat/utils/diagnostics.test.ts +++ b/apps/web/src/lib/diagnostics.test.ts @@ -1,18 +1,18 @@ import { describe, expect, test } from "bun:test"; -import { bucketMessagesAdded } from "@/domains/chat/utils/diagnostics"; +import { bucketMessagesAdded } from "@/lib/diagnostics"; describe("bucketMessagesAdded", () => { // Buckets must stay low-cardinality so the values are aggregable // as Sentry tags. Bands are chosen so 0 (no rescue) and 1 (single - // missed message — the LUM-1431 shape) are distinguishable, and + // missed message — the iOS watchdog shape) are distinguishable, and // larger rescues collapse into coarser buckets where the exact // count matters less than "this happened." test("returns '0' for no rescue", () => { expect(bucketMessagesAdded(0)).toBe("0"); }); - test("returns '1' for the single-missed-message LUM-1431 shape", () => { + test("returns '1' for the single-missed-message iOS watchdog shape", () => { expect(bucketMessagesAdded(1)).toBe("1"); }); diff --git a/apps/web/src/lib/diagnostics.ts b/apps/web/src/lib/diagnostics.ts new file mode 100644 index 00000000000..c81bf2e7f47 --- /dev/null +++ b/apps/web/src/lib/diagnostics.ts @@ -0,0 +1,293 @@ +/** + * Session-scoped diagnostic event recorder. + * + * Records timestamped diagnostic events to a sessionStorage ring buffer. + * Used by the streaming transport, chat hooks, and the support diagnostics + * snapshot. Data survives navigation but not tab close. + */ + +import { Capacitor } from "@capacitor/core"; + +import type { AssistantEvent } from "@/types/event-types"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DiagnosticsEvent { + ts: string; + kind: string; + details: Record; +} + +// --------------------------------------------------------------------------- +// Module state +// --------------------------------------------------------------------------- + +const MAX_EVENTS = 200; +const STORAGE_KEY = "vellum:chat-diagnostics:v1"; + +let loaded = false; +let events: DiagnosticsEvent[] = []; + +// --------------------------------------------------------------------------- +// Storage helpers +// --------------------------------------------------------------------------- + +function getSessionStorage(): Storage | null { + if (typeof window === "undefined") return null; + try { + return window.sessionStorage; + } catch { + return null; + } +} + +function loadEvents(): void { + if (loaded) return; + loaded = true; + const storage = getSessionStorage(); + if (!storage) return; + try { + const raw = storage.getItem(STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return; + events = parsed + .filter( + (event): event is DiagnosticsEvent => + event != null && + typeof event === "object" && + typeof event.ts === "string" && + typeof event.kind === "string" && + event.details != null && + typeof event.details === "object" && + !Array.isArray(event.details), + ) + .slice(-MAX_EVENTS); + } catch { + events = []; + } +} + +function saveEvents(): void { + const storage = getSessionStorage(); + if (!storage) return; + try { + storage.setItem(STORAGE_KEY, JSON.stringify(events)); + } catch { + // Diagnostics are best-effort and must never affect app behavior. + } +} + +// --------------------------------------------------------------------------- +// Platform detection +// --------------------------------------------------------------------------- + +/** Resolve the runtime platform tag (ios, android, or web). */ +export function resolvePlatformTag(): string { + try { + const platform = ( + Capacitor as unknown as { getPlatform?: () => string } + ).getPlatform?.(); + if (typeof platform === "string" && platform.length > 0) { + return platform; + } + } catch { + // fall through + } + return "web"; +} + +// --------------------------------------------------------------------------- +// Recording +// --------------------------------------------------------------------------- + +/** Append a diagnostic event to the sessionStorage ring buffer. */ +export function recordDiagnostic( + kind: string, + details: Record = {}, +): void { + loadEvents(); + events.push({ + ts: new Date().toISOString(), + kind, + details: { platform: resolvePlatformTag(), ...details }, + }); + if (events.length > MAX_EVENTS) { + events = events.slice(-MAX_EVENTS); + } + saveEvents(); +} + +/** Return a defensive copy of all recorded diagnostic events. */ +export function getDiagnosticsEvents(): DiagnosticsEvent[] { + loadEvents(); + return events.map((event) => ({ + ts: event.ts, + kind: event.kind, + details: { ...event.details }, + })); +} + +/** Build a timestamped diagnostics snapshot for support submissions. */ +export function buildDiagnosticsSnapshot( + currentState: Record | null, +): Record { + return { + schemaVersion: 1, + collectedAt: new Date().toISOString(), + currentState, + events: getDiagnosticsEvents(), + }; +} + +// --------------------------------------------------------------------------- +// Event summarization +// --------------------------------------------------------------------------- + +/** Bucket a message count into a low-cardinality Sentry tag band. */ +export function bucketMessagesAdded(count: number): string { + if (!Number.isFinite(count) || count <= 0) return "0"; + if (count === 1) return "1"; + if (count <= 5) return "2-5"; + return "6+"; +} + +/** Count messages per role. */ +export function roleCounts(messages: Array<{ role: string }>): Record { + const counts: Record = {}; + for (const message of messages) { + counts[message.role] = (counts[message.role] ?? 0) + 1; + } + return counts; +} + +// --------------------------------------------------------------------------- +// Assistant event summarization (safe field extraction) +// --------------------------------------------------------------------------- + +function copyStringField( + summary: Record, + record: Record, + key: string, +): void { + if (typeof record[key] === "string") { + summary[key] = record[key]; + } +} + +function copyNumberField( + summary: Record, + record: Record, + key: string, +): void { + if (typeof record[key] === "number" && Number.isFinite(record[key])) { + summary[key] = record[key]; + } +} + +function copyBooleanField( + summary: Record, + record: Record, + key: string, +): void { + if (typeof record[key] === "boolean") { + summary[key] = record[key]; + } +} + +function copyStringLengthField( + summary: Record, + record: Record, + key: string, +): void { + if (typeof record[key] === "string") { + summary[`${key}Length`] = record[key].length; + } +} + +/** Extract a compact summary of an SSE event for diagnostic logging. */ +export function summarizeAssistantEvent( + event: AssistantEvent, +): Record { + const record = event as unknown as Record; + const summary: Record = { type: event.type }; + + for (const key of [ + "messageId", + "requestId", + "surfaceId", + "surfaceType", + "toolUseId", + "conversationId", + "deliveryId", + "code", + "toolName", + "errorCategory", + "rawType", + "tab", + "sourceEventName", + ]) { + copyStringField(summary, record, key); + } + for (const key of [ + "position", + "inputTokens", + "outputTokens", + "cachedInputTokens", + "cacheCreationInputTokens", + "contextWindowTokens", + "contextWindowMaxTokens", + "openUntil", + ]) { + copyNumberField(summary, record, key); + } + for (const key of ["isError", "retryable", "runStillActive"]) { + copyBooleanField(summary, record, key); + } + for (const key of [ + "text", + "content", + "message", + "userMessage", + "debugDetails", + "title", + "body", + "summary", + "result", + "url", + ]) { + copyStringLengthField(summary, record, key); + } + + if (typeof record.url === "string") { + try { + summary.urlHost = new URL(record.url).host; + } catch { + summary.urlHost = null; + } + } + if (Array.isArray(record.attachments)) { + summary.attachmentCount = record.attachments.length; + } + if (Array.isArray(record.actions)) { + summary.actionCount = record.actions.length; + } + if ( + record.data != null && + typeof record.data === "object" && + !Array.isArray(record.data) + ) { + summary.dataKeys = Object.keys(record.data).length; + } + if ( + record.input != null && + typeof record.input === "object" && + !Array.isArray(record.input) + ) { + summary.inputKeys = Object.keys(record.input).length; + } + + return summary; +} diff --git a/apps/web/src/domains/chat/api/event-parser.test.ts b/apps/web/src/lib/streaming/event-parser.test.ts similarity index 99% rename from apps/web/src/domains/chat/api/event-parser.test.ts rename to apps/web/src/lib/streaming/event-parser.test.ts index 0286ab1a69a..fe054b6e9af 100644 --- a/apps/web/src/domains/chat/api/event-parser.test.ts +++ b/apps/web/src/lib/streaming/event-parser.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"; import { parseAssistantEvent, toDisplayAttachments, -} from "@/domains/chat/api/event-parser"; +} from "@/lib/streaming/event-parser"; import { SYNC_TAGS } from "@/lib/sync/types"; describe("parseAssistantEvent", () => { @@ -2011,7 +2011,7 @@ describe("envelope format parsing", () => { describe("RuntimeMessage metadata types", () => { test("RuntimeMessage interface accepts optional metadata fields", () => { // Type-level test: ensure RuntimeMessage can carry metadata - const msg: import("./messages").RuntimeMessage = { + const msg: import("@/domains/chat/api/messages").RuntimeMessage = { id: "msg-1", role: "assistant", content: "Hello", @@ -2036,7 +2036,7 @@ describe("RuntimeMessage metadata types", () => { }); test("RuntimeMessage works without metadata fields", () => { - const msg: import("./messages").RuntimeMessage = { + const msg: import("@/domains/chat/api/messages").RuntimeMessage = { id: "msg-2", role: "user", content: "Hi", @@ -2048,7 +2048,7 @@ describe("RuntimeMessage metadata types", () => { }); test("ChatMessage interface accepts optional metadata fields", () => { - const msg: import("./event-types").ChatMessage = { + const msg: import("@/domains/chat/api/event-types").ChatMessage = { id: "msg-3", role: "assistant", content: "With metadata", diff --git a/apps/web/src/domains/chat/api/event-parser.ts b/apps/web/src/lib/streaming/event-parser.ts similarity index 99% rename from apps/web/src/domains/chat/api/event-parser.ts rename to apps/web/src/lib/streaming/event-parser.ts index 6e85f005f45..d2669160a7f 100644 --- a/apps/web/src/domains/chat/api/event-parser.ts +++ b/apps/web/src/lib/streaming/event-parser.ts @@ -31,7 +31,7 @@ import type { } from "@/types/interaction-ui-types"; import type { AssistantOutboundAttachment } from "@vellumai/assistant-api"; import { AssistantEventSchema } from "@vellumai/assistant-api"; -import type { DisplayAttachment } from "@/domains/chat/types/types"; +import type { DisplayAttachment } from "@/types/attachment-types"; import type { ToolActivityMetadata } from "@/assistant/web-activity-types"; import type { SyncInvalidationTag } from "@/lib/sync/types"; diff --git a/apps/web/src/domains/chat/api/stream-debug.test.ts b/apps/web/src/lib/streaming/stream-debug.test.ts similarity index 99% rename from apps/web/src/domains/chat/api/stream-debug.test.ts rename to apps/web/src/lib/streaming/stream-debug.test.ts index 9f4f86e7da7..18f4b740362 100644 --- a/apps/web/src/domains/chat/api/stream-debug.test.ts +++ b/apps/web/src/lib/streaming/stream-debug.test.ts @@ -8,7 +8,7 @@ import { registerSseClient, resetSseDebugStateForTests, unregisterSseClient, -} from "@/domains/chat/api/stream-debug"; +} from "@/lib/streaming/stream-debug"; import type { AssistantEvent } from "@/types/event-types"; beforeEach(() => { diff --git a/apps/web/src/domains/chat/api/stream-debug.ts b/apps/web/src/lib/streaming/stream-debug.ts similarity index 98% rename from apps/web/src/domains/chat/api/stream-debug.ts rename to apps/web/src/lib/streaming/stream-debug.ts index f347f585961..f5b472fbdeb 100644 --- a/apps/web/src/domains/chat/api/stream-debug.ts +++ b/apps/web/src/lib/streaming/stream-debug.ts @@ -1,7 +1,7 @@ /** * Module-level SSE stream debugging tracker. * - * Records every SSE event that flows through {@link subscribeChatEvents} + * Records every SSE event that flows through the stream transport layer * and maintains a lightweight registry of active/past stream clients. * * Data is stored outside React state so it survives component unmounts and diff --git a/apps/web/src/domains/chat/api/stream.test.ts b/apps/web/src/lib/streaming/stream-transport.test.ts similarity index 97% rename from apps/web/src/domains/chat/api/stream.test.ts rename to apps/web/src/lib/streaming/stream-transport.test.ts index 9ee80252e42..1f27ea039d4 100644 --- a/apps/web/src/domains/chat/api/stream.test.ts +++ b/apps/web/src/lib/streaming/stream-transport.test.ts @@ -44,16 +44,16 @@ mock.module("@sentry/browser", () => ({ })); import { - getChatDiagnosticsEvents, -} from "@/domains/chat/utils/diagnostics"; + getDiagnosticsEvents, +} from "@/lib/diagnostics"; import { type TurnState, INITIAL_TURN_STATE, turnReducer, isSending, } from "@/domains/chat/turn-store"; -import { parseAssistantEvent } from "@/domains/chat/api/event-parser"; -import { subscribeChatEvents, type ChatStreamReconnectCause } from "@/domains/chat/api/stream"; +import { parseAssistantEvent } from "@/lib/streaming/event-parser"; +import { subscribeChatEvents, type ChatStreamReconnectCause } from "@/lib/streaming/stream-transport"; import { useAssistantIdentityStore } from "@/stores/assistant-identity-store"; describe("polling reconciliation with state machine", () => { @@ -629,7 +629,7 @@ describe("subscribeChatEvents idle watchdog", () => { ), ) as unknown as typeof fetch; - const eventCountBefore = getChatDiagnosticsEvents().length; + const eventCountBefore = getDiagnosticsEvents().length; const breadcrumbsBefore = sentryBreadcrumbs.length; const captureMessagesBefore = sentryCaptureMessages.length; @@ -645,7 +645,7 @@ describe("subscribeChatEvents idle watchdog", () => { // Comfortably past the first watchdog fire (~50ms). await new Promise((r) => setTimeout(r, 200)); - const newEvents = getChatDiagnosticsEvents().slice(eventCountBefore); + const newEvents = getDiagnosticsEvents().slice(eventCountBefore); const fires = newEvents.filter( (event) => event.kind === "sse_watchdog_fired", ); @@ -659,7 +659,7 @@ describe("subscribeChatEvents idle watchdog", () => { // The first watchdog fire happens on the very first connect // attempt, before any reconnect has incremented the counter. expect(first.details.attempt).toBe(0); - // Centralized platform tag is injected by recordChatDiagnostic. + // Centralized platform tag is injected by recordDiagnostic. expect(first.details.platform).toBe("web"); // Sentry mirrors are how fleet data answers the L2/L3 question. @@ -730,7 +730,7 @@ describe("subscribeChatEvents idle watchdog", () => { ), ) as unknown as typeof fetch; - const eventCountBefore = getChatDiagnosticsEvents().length; + const eventCountBefore = getDiagnosticsEvents().length; const captureMessagesBefore = sentryCaptureMessages.length; const sub = subscribeChatEvents( @@ -751,7 +751,7 @@ describe("subscribeChatEvents idle watchdog", () => { try { await new Promise((r) => setTimeout(r, 200)); - const newEvents = getChatDiagnosticsEvents().slice(eventCountBefore); + const newEvents = getDiagnosticsEvents().slice(eventCountBefore); const firstFire = newEvents.find( (event) => event.kind === "sse_watchdog_fired", ); @@ -801,7 +801,7 @@ describe("subscribeChatEvents idle watchdog", () => { test("tags wasTurnSending: 'unknown' when no getActiveTurnSending snapshot is supplied", async () => { // Backwards compatibility: callers that have not yet wired the // turn-sending snapshot (e.g. unit tests of subscribeChatEvents - // in isolation, or any caller pre-LUM-1538) must still produce + // in isolation, or any caller without turn-sending wiring) must still produce // a tag value, not omit the field. Sentry groups absent tags as // `""` in Discover, which collides with healthy events // that legitimately have no value. Sending `"unknown"` makes @@ -892,7 +892,7 @@ describe("subscribeChatEvents idle watchdog", () => { ), ) as unknown as typeof fetch; - const eventCountBefore = getChatDiagnosticsEvents().length; + const eventCountBefore = getDiagnosticsEvents().length; const sub = subscribeChatEvents( "asst-heartbeat", @@ -907,7 +907,7 @@ describe("subscribeChatEvents idle watchdog", () => { // idleTimeoutMs = ~120ms. 250ms gives comfortable margin. await new Promise((r) => setTimeout(r, 250)); - const newEvents = getChatDiagnosticsEvents().slice(eventCountBefore); + const newEvents = getDiagnosticsEvents().slice(eventCountBefore); const firstFire = newEvents.find( (event) => event.kind === "sse_watchdog_fired", ); @@ -1016,7 +1016,7 @@ describe("subscribeChatEvents idle watchdog", () => { }) as unknown as typeof fetch; const causes: ChatStreamReconnectCause[] = []; - const eventCountBefore = getChatDiagnosticsEvents().length; + const eventCountBefore = getDiagnosticsEvents().length; const sub = subscribeChatEvents( "asst-stale", @@ -1049,7 +1049,7 @@ describe("subscribeChatEvents idle watchdog", () => { // No sse_watchdog_fired diagnostic should have been recorded // for this subscription — every fetch errored before its // watchdog deadline, so any fire is from a stale timer. - const newEvents = getChatDiagnosticsEvents().slice(eventCountBefore); + const newEvents = getDiagnosticsEvents().slice(eventCountBefore); const fires = newEvents.filter( (event) => event.kind === "sse_watchdog_fired" && diff --git a/apps/web/src/domains/chat/api/stream.ts b/apps/web/src/lib/streaming/stream-transport.ts similarity index 97% rename from apps/web/src/domains/chat/api/stream.ts rename to apps/web/src/lib/streaming/stream-transport.ts index 8a0a3884888..fb8ab33f6bb 100644 --- a/apps/web/src/domains/chat/api/stream.ts +++ b/apps/web/src/lib/streaming/stream-transport.ts @@ -8,9 +8,10 @@ import * as Sentry from "@sentry/browser"; -import { client, SDK_BASE_OPTIONS } from "@/domains/chat/api/client"; -import { recordChatDiagnostic, resolvePlatformTag } from "@/domains/chat/utils/diagnostics"; -import { parseAssistantEvent } from "@/domains/chat/api/event-parser"; +import { client } from "@/generated/api/client.gen"; +import { SDK_BASE_OPTIONS } from "@/utils/api-errors"; +import { recordDiagnostic, resolvePlatformTag } from "@/lib/diagnostics"; +import { parseAssistantEvent } from "@/lib/streaming/event-parser"; import type { AssistantEvent } from "@/types/event-types"; import { pickConversationIdWireField } from "@/lib/backwards-compat/conversation-id-wire-field"; import { getClientRegistrationHeaders } from "@/lib/telemetry/client-identity"; @@ -19,7 +20,7 @@ import { pushSseEvent, registerSseClient, unregisterSseClient, -} from "@/domains/chat/api/stream-debug"; +} from "@/lib/streaming/stream-debug"; // --------------------------------------------------------------------------- // SSE stream transport @@ -201,7 +202,7 @@ export function subscribeChatEvents( // Record before aborting so the diagnostic captures the // attempt that actually stalled, even if the abort cascade // tears state down before the next reconnect runs. - recordChatDiagnostic("sse_watchdog_fired", { + recordDiagnostic("sse_watchdog_fired", { assistantId, conversationId: requestedConversationId ?? null, attempt: reconnectCount, @@ -239,7 +240,7 @@ export function subscribeChatEvents( level: "warning", // platform is the only fleet-wide signal that distinguishes // Capacitor iOS from Safari iOS — Sentry's auto-detected - // os.name does not, but LUM-1431 is iOS-only so the L2/L3 + // os.name does not, but the idle-watchdog is iOS-only so the L2/L3 // decision needs the breakdown. tags are aggregable in // Discover; extras are not. wasTurnSending is promoted to a // tag so the user-harming vs benign split can be queried in diff --git a/apps/web/src/lib/threshold-api.ts b/apps/web/src/lib/threshold-api.ts index df95c7e611e..8ec24d4d6a9 100644 --- a/apps/web/src/lib/threshold-api.ts +++ b/apps/web/src/lib/threshold-api.ts @@ -6,13 +6,13 @@ * functions here use the raw HeyAPI client until gateway codegen is wired up. */ +import { client } from "@/generated/api/client.gen"; import { ApiError, assertHasResponse, - client, extractErrorMessage, SDK_BASE_OPTIONS, -} from "@/domains/chat/api/client"; +} from "@/utils/api-errors"; export interface GlobalThresholds { interactive: string; diff --git a/apps/web/src/types/attachment-types.ts b/apps/web/src/types/attachment-types.ts new file mode 100644 index 00000000000..50c60d16e17 --- /dev/null +++ b/apps/web/src/types/attachment-types.ts @@ -0,0 +1,13 @@ +/** Display metadata for a file attachment (user-uploaded or assistant-generated), + * used to render the chip inside a message bubble. For live sessions, populated + * from SSE event data via `toDisplayAttachments` in the streaming domain. For + * history reload, populated from the daemon's structured attachment metadata + * (real UUIDs that resolve against the content endpoint) or, as a fallback, + * reverse-parsed from `[File attachment] …` summary lines in the message text. */ +export interface DisplayAttachment { + id: string; + filename: string; + mimeType: string; + sizeBytes: number; + previewUrl: string | null; +}