diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index d0b442271..0e667f2cf 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -20,6 +20,8 @@ import MobileSwitcherSheet from './components/MobileSwitcherSheet.vue'; import MobileSettingsSheet from './components/MobileSettingsSheet.vue'; import Onboarding from './components/Onboarding.vue'; import GlobalLoading from './components/GlobalLoading.vue'; +import DebugPanel from './debug/DebugPanel.vue'; +import { isTraceEnabled } from './debug/trace'; import { useKimiWebClient } from './composables/useKimiWebClient'; import { useIsMobile } from './composables/useIsMobile'; import type { ThinkingLevel } from './api/types'; @@ -29,6 +31,9 @@ const client = useKimiWebClient(); provide('resolveImage', client.resolveImageUrl); const { t } = useI18n(); +// KAP/daemon debug panel — opt-in via ?debug=1 or localStorage kimi-web.debug=1. +const debugEnabled = isTraceEnabled(); + // Narrow viewports (≤640px) render the single-column mobile shell; desktop is // unchanged. jsdom defaults to false (desktop) so component tests are unaffected. const isMobile = useIsMobile(); @@ -803,6 +808,9 @@ function handleCreateSessionInWorkspace(workspaceId: string): void { + + + > = []; + formData.forEach((value, field) => { + if (typeof value === 'string') { + fields.push({ field, value }); + } else { + fields.push({ field, file: value.name, size: value.size, type: value.type }); + } + }); + return { formData: fields }; + } catch { + return '[FormData]'; + } +} + async function readResponsePreview(response: Response): Promise { try { const text = await response.text(); @@ -76,10 +94,13 @@ export class DaemonHttpClient { const headers: Record = { 'X-Request-Id': requestId, }; + const startedAt = Date.now(); + traceRestRequest({ method: 'POST', path, url, requestId, body: describeFormData(formData) }); let response: Response; try { response = await fetch(url, { method: 'POST', headers, body: formData, signal: timeoutSignal() }); } catch (err) { + traceRestFailure({ method: 'POST', path, requestId, phase: 'fetch', durationMs: Date.now() - startedAt, error: err }); throw new DaemonNetworkError({ message: `Network error calling POST ${path}`, cause: err, @@ -96,6 +117,7 @@ export class DaemonHttpClient { try { envelope = (await response.json()) as WireEnvelope; } catch (err) { + traceRestFailure({ method: 'POST', path, requestId, phase: 'parse', durationMs: Date.now() - startedAt, status: response.status, error: err }); throw new DaemonNetworkError({ message: `Failed to parse JSON response from POST ${path}`, cause: err, @@ -111,6 +133,17 @@ export class DaemonHttpClient { bodyPreview: await readResponsePreview(responseForDiagnostics), }); } + traceRestResponse({ + method: 'POST', + path, + requestId, + status: response.status, + durationMs: Date.now() - startedAt, + code: envelope.code, + msg: envelope.msg, + envelopeRequestId: envelope.request_id, + data: envelope.data, + }); if (envelope.code !== 0) { throw new DaemonApiError({ code: envelope.code, @@ -159,6 +192,9 @@ export class DaemonHttpClient { headers['Content-Type'] = 'application/json; charset=utf-8'; } + const startedAt = Date.now(); + traceRestRequest({ method, path, url, requestId, body }); + // Execute fetch let response: Response; try { @@ -169,6 +205,7 @@ export class DaemonHttpClient { signal: timeoutSignal(), }); } catch (err) { + traceRestFailure({ method, path, requestId, phase: 'fetch', durationMs: Date.now() - startedAt, error: err }); throw new DaemonNetworkError({ message: `Network error calling ${method} ${path}`, cause: err, @@ -187,6 +224,7 @@ export class DaemonHttpClient { try { envelope = (await response.json()) as WireEnvelope; } catch (err) { + traceRestFailure({ method, path, requestId, phase: 'parse', durationMs: Date.now() - startedAt, status: response.status, error: err }); throw new DaemonNetworkError({ message: `Failed to parse JSON response from ${method} ${path}`, cause: err, @@ -203,6 +241,18 @@ export class DaemonHttpClient { }); } + traceRestResponse({ + method, + path, + requestId, + status: response.status, + durationMs: Date.now() - startedAt, + code: envelope.code, + msg: envelope.msg, + envelopeRequestId: envelope.request_id, + data: envelope.data, + }); + // Unwrap: code 0 = success; allowed non-zero = return data; else throw if (envelope.code !== 0 && !allowCodes.includes(envelope.code)) { throw new DaemonApiError({ diff --git a/apps/kimi-web/src/api/daemon/ws.ts b/apps/kimi-web/src/api/daemon/ws.ts index fe8fc254a..f7fbae85e 100644 --- a/apps/kimi-web/src/api/daemon/ws.ts +++ b/apps/kimi-web/src/api/daemon/ws.ts @@ -3,6 +3,7 @@ // Handles: server_hello / client_hello handshake, subscribe/unsubscribe, // ping/pong heartbeat, resync_required, error frames, event.* dispatch. +import { traceWsIn, traceWsLifecycle, traceWsOut } from '../../debug/trace'; import { classifyFrame } from './agentEventProjector'; import type { WireEvent, WireServerFrame } from './wire'; @@ -78,18 +79,22 @@ export class DaemonEventSocket { connect(): void { if (this.ws !== null || this.closed) return; + traceWsLifecycle('connect', { url: this.wsUrl, attempt: this.reconnectAttempts }); const ws = new WebSocket(this.wsUrl); this.ws = ws; ws.onopen = () => { // Don't mark as connected yet — wait for server_hello + traceWsLifecycle('open'); }; ws.onmessage = (ev: MessageEvent) => { try { const frame = JSON.parse(String(ev.data)) as WireServerFrame; + traceWsIn(frame); this.handleFrame(frame); } catch (err) { + traceWsLifecycle('parse-error', { error: String(err) }); this.handlers.onError(0, `Failed to parse WS frame: ${String(err)}`, false); } }; @@ -97,10 +102,12 @@ export class DaemonEventSocket { ws.onerror = () => { // The error details are not exposed by the browser WS API; the close // event with a reason code follows immediately. + traceWsLifecycle('error'); this.handlers.onError(0, 'WebSocket error', false); }; - ws.onclose = () => { + ws.onclose = (ev?: CloseEvent) => { + traceWsLifecycle('close', ev ? { code: ev.code, reason: ev.reason, wasClean: ev.wasClean } : undefined); this.connected = false; this.ws = null; this.handlers.onConnectionState(false); @@ -117,6 +124,7 @@ export class DaemonEventSocket { const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts); const delay = base + Math.floor(Math.random() * 250); // jitter this.reconnectAttempts += 1; + traceWsLifecycle('reconnect-scheduled', { delayMs: delay, attempt: this.reconnectAttempts }); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(); @@ -347,6 +355,7 @@ export class DaemonEventSocket { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; try { this.ws.send(JSON.stringify(msg)); + traceWsOut(msg); } catch { // Ignore send errors (socket closing races) } diff --git a/apps/kimi-web/src/debug/DebugPanel.vue b/apps/kimi-web/src/debug/DebugPanel.vue new file mode 100644 index 000000000..c1ae66402 --- /dev/null +++ b/apps/kimi-web/src/debug/DebugPanel.vue @@ -0,0 +1,408 @@ + + + + + + diff --git a/apps/kimi-web/src/debug/trace.ts b/apps/kimi-web/src/debug/trace.ts new file mode 100644 index 000000000..a88706c7d --- /dev/null +++ b/apps/kimi-web/src/debug/trace.ts @@ -0,0 +1,308 @@ +// apps/kimi-web/src/debug/trace.ts +// KAP/daemon debug trace — a side-channel recording of REST calls and WS +// frames, kept in a bounded ring buffer for the opt-in debug panel. +// +// Opt-in: `?debug=1` in the URL or `localStorage["kimi-web.debug"]="1"`. +// When not enabled every record call is a single boolean check, and nothing +// is stored — normal use pays (almost) nothing. Recording NEVER changes the +// request/WS behavior: callers pass data in, errors here must not propagate. + +import { ref, shallowRef } from 'vue'; + +export type TraceSource = 'rest' | 'ws'; + +export interface TraceEntry { + id: number; + /** Epoch ms when recorded. */ + ts: number; + source: TraceSource; + /** + * rest:request | rest:response | rest:error + * ws:lifecycle (connect/open/close/error/reconnect) | ws:in | ws:out + */ + kind: string; + /** One-line summary for the timeline. */ + label: string; + sessionId?: string; + /** REST method + path (for filtering/aggregation). */ + method?: string; + path?: string; + /** WS frame type (server_hello, ping, event.* / raw agent type, …). */ + eventType?: string; + seq?: number; + offset?: number; + /** HTTP status (REST). */ + status?: number; + /** Envelope code (REST) — 0 is success. */ + code?: number; + requestId?: string; + durationMs?: number; + /** Sanitized + truncated payload for the detail view. */ + detail?: unknown; +} + +const MAX_ENTRIES = 1000; +/** A single entry's detail JSON is capped so one giant frame (e.g. a snapshot + with full scrollback) can't dominate the buffer's memory. */ +const MAX_DETAIL_JSON_CHARS = 16_384; +const MAX_STRING = 500; +const MAX_ARRAY_ITEMS = 50; +const MAX_DEPTH = 6; + +const SENSITIVE_KEY_RE = /api[_-]?key|authorization|token|secret|password|cookie|credential/i; +/** Long unbroken base64-ish runs (uploads, inlined images) are size, not signal. */ +const BASE64ISH_RE = /^[A-Za-z0-9+/=_-]{200,}$/; + +// --------------------------------------------------------------------------- +// Enablement — resolved lazily on first use so tests (and a user flipping the +// localStorage flag before load) are honored without module-import ordering. +// --------------------------------------------------------------------------- + +let enabledCache: boolean | null = null; + +export function isTraceEnabled(): boolean { + if (enabledCache !== null) return enabledCache; + let enabled = false; + try { + if (typeof location !== 'undefined') { + const v = new URLSearchParams(location.search).get('debug'); + if (v === '1' || v === 'true') enabled = true; + } + } catch { + // location unavailable + } + if (!enabled) { + try { + enabled = localStorage.getItem('kimi-web.debug') === '1'; + } catch { + // localStorage unavailable + } + } + enabledCache = enabled; + return enabled; +} + +// --------------------------------------------------------------------------- +// Ring buffer + reactivity +// --------------------------------------------------------------------------- + +const entries: TraceEntry[] = []; +let nextId = 1; + +/** Bumped on every push; the panel re-reads the buffer when it changes. */ +export const traceVersion = ref(0); +/** While true new records are dropped (panel "pause" button). */ +export const tracePaused = shallowRef(false); + +export function traceEntries(): readonly TraceEntry[] { + return entries; +} + +export function clearTrace(): void { + entries.length = 0; + traceVersion.value++; +} + +function push(entry: Omit): void { + if (tracePaused.value) return; + entries.push({ id: nextId++, ts: Date.now(), ...entry }); + if (entries.length > MAX_ENTRIES) entries.splice(0, entries.length - MAX_ENTRIES); + traceVersion.value++; +} + +// --------------------------------------------------------------------------- +// Sanitization — redact sensitive keys, truncate long strings/arrays/depth. +// --------------------------------------------------------------------------- + +export function sanitizeForTrace(value: unknown, depth = 0): unknown { + if (value === null || value === undefined) return value; + const t = typeof value; + if (t === 'number' || t === 'boolean') return value; + if (t === 'string') { + const s = value as string; + if (BASE64ISH_RE.test(s)) return `[base64-like, ${s.length} chars omitted]`; + if (s.length > MAX_STRING) return `${s.slice(0, MAX_STRING)}… [+${s.length - MAX_STRING} chars]`; + return s; + } + if (t !== 'object') return String(value); + if (depth >= MAX_DEPTH) return '[max depth]'; + if (Array.isArray(value)) { + const out: unknown[] = value + .slice(0, MAX_ARRAY_ITEMS) + .map((v) => sanitizeForTrace(v, depth + 1)); + if (value.length > MAX_ARRAY_ITEMS) out.push(`[+${value.length - MAX_ARRAY_ITEMS} more items]`); + return out; + } + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = SENSITIVE_KEY_RE.test(k) ? '[redacted]' : sanitizeForTrace(v, depth + 1); + } + return out; +} + +/** Sanitize, then hard-cap the serialized size of one entry's detail. */ +function detailOf(value: unknown): unknown { + if (value === undefined) return undefined; + const sanitized = sanitizeForTrace(value); + try { + const json = JSON.stringify(sanitized); + if (json !== undefined && json.length > MAX_DETAIL_JSON_CHARS) { + return { + _truncated: `detail JSON was ${json.length} chars; first ${MAX_DETAIL_JSON_CHARS} kept`, + preview: json.slice(0, MAX_DETAIL_JSON_CHARS), + }; + } + } catch { + return '[unserializable detail]'; + } + return sanitized; +} + +// --------------------------------------------------------------------------- +// REST recording — called from DaemonHttpClient +// --------------------------------------------------------------------------- + +export function traceRestRequest(info: { + method: string; + path: string; + url: string; + requestId: string; + body?: unknown; +}): void { + if (!isTraceEnabled()) return; + push({ + source: 'rest', + kind: 'rest:request', + label: `→ ${info.method} ${info.path}`, + method: info.method, + path: info.path, + requestId: info.requestId, + detail: { url: info.url, body: detailOf(info.body) }, + }); +} + +export function traceRestResponse(info: { + method: string; + path: string; + requestId: string; + status: number; + durationMs: number; + code: number; + msg: string; + envelopeRequestId?: string; + data?: unknown; +}): void { + if (!isTraceEnabled()) return; + const failed = info.code !== 0; + push({ + source: 'rest', + kind: failed ? 'rest:error' : 'rest:response', + label: `← ${info.method} ${info.path} ${info.status} code=${info.code}${failed ? ` "${info.msg}"` : ''} ${Math.round(info.durationMs)}ms`, + method: info.method, + path: info.path, + requestId: info.requestId, + status: info.status, + code: info.code, + durationMs: info.durationMs, + detail: { + envelope: { code: info.code, msg: info.msg, request_id: info.envelopeRequestId }, + data: detailOf(info.data), + }, + }); +} + +export function traceRestFailure(info: { + method: string; + path: string; + requestId: string; + phase: 'fetch' | 'parse'; + durationMs: number; + status?: number; + error: unknown; +}): void { + if (!isTraceEnabled()) return; + push({ + source: 'rest', + kind: 'rest:error', + label: `✕ ${info.method} ${info.path} ${info.phase} error${info.status !== undefined ? ` (HTTP ${info.status})` : ''} ${Math.round(info.durationMs)}ms`, + method: info.method, + path: info.path, + requestId: info.requestId, + status: info.status, + durationMs: info.durationMs, + detail: { phase: info.phase, error: String(info.error) }, + }); +} + +// --------------------------------------------------------------------------- +// WS recording — called from DaemonEventSocket +// --------------------------------------------------------------------------- + +export function traceWsLifecycle(event: string, detail?: unknown): void { + if (!isTraceEnabled()) return; + push({ + source: 'ws', + kind: 'ws:lifecycle', + eventType: event, + label: `ws ${event}`, + detail: detailOf(detail), + }); +} + +/** Outbound client frame (client_hello / subscribe / unsubscribe / abort / pong). */ +export function traceWsOut(frame: unknown): void { + if (!isTraceEnabled()) return; + const f = (frame ?? {}) as Record; + const type = typeof f['type'] === 'string' ? (f['type'] as string) : '(unknown)'; + const payload = f['payload'] as Record | undefined; + const sessionId = + typeof payload?.['session_id'] === 'string' ? (payload['session_id'] as string) : undefined; + push({ + source: 'ws', + kind: 'ws:out', + eventType: type, + sessionId, + label: `→ ${type}`, + detail: detailOf(frame), + }); +} + +/** Inbound server frame — control frames and event frames alike. */ +export function traceWsIn(frame: unknown): void { + if (!isTraceEnabled()) return; + const f = (frame ?? {}) as Record; + const type = typeof f['type'] === 'string' ? (f['type'] as string) : '(unknown)'; + const sessionId = + typeof f['session_id'] === 'string' + ? (f['session_id'] as string) + : typeof (f['payload'] as Record | undefined)?.['session_id'] === 'string' + ? ((f['payload'] as Record)['session_id'] as string) + : undefined; + const seq = typeof f['seq'] === 'number' ? (f['seq'] as number) : undefined; + const offset = typeof f['offset'] === 'number' ? (f['offset'] as number) : undefined; + const bits = [ + sessionId, + seq !== undefined ? `seq=${seq}` : undefined, + offset !== undefined ? `offset=${offset}` : undefined, + f['volatile'] === true ? 'volatile' : undefined, + ].filter(Boolean); + push({ + source: 'ws', + kind: 'ws:in', + eventType: type, + sessionId, + seq, + offset, + label: `← ${type}${bits.length > 0 ? ` (${bits.join(' ')})` : ''}`, + detail: detailOf(f['payload']), + }); +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +/** Serialize the given entries (default: all) as JSONL for download. */ +export function traceToJsonl(list: readonly TraceEntry[] = entries): string { + return list.map((e) => JSON.stringify(e)).join('\n'); +} diff --git a/apps/kimi-web/test/debug-trace.test.ts b/apps/kimi-web/test/debug-trace.test.ts new file mode 100644 index 000000000..fb82c642e --- /dev/null +++ b/apps/kimi-web/test/debug-trace.test.ts @@ -0,0 +1,213 @@ +// apps/kimi-web/test/debug-trace.test.ts +// +// KAP debug trace: the side-channel recording of REST calls and WS frames. +// Drives the REAL DaemonHttpClient (stubbed fetch) and DaemonEventSocket +// (stubbed WebSocket) and asserts what a user would see in the debug panel: +// request/response/error entries, redacted secrets, truncated payloads, +// bounded buffer, JSONL export. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DaemonHttpClient } from '../src/api/daemon/http'; +import { DaemonEventSocket, type DaemonEventSocketHandlers } from '../src/api/daemon/ws'; +import { + clearTrace, + sanitizeForTrace, + traceEntries, + traceToJsonl, + traceWsIn, +} from '../src/debug/trace'; + +function okEnvelope(data: unknown): Response { + return new Response( + JSON.stringify({ code: 0, msg: 'ok', data, request_id: 'req_env_1' }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); +} + +function errEnvelope(code: number, msg: string): Response { + return new Response( + JSON.stringify({ code, msg, data: null, request_id: 'req_env_2' }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); +} + +beforeEach(() => { + // Opt the trace in the way a user would (the localStorage switch). + localStorage.setItem('kimi-web.debug', '1'); + clearTrace(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('REST tracing via DaemonHttpClient', () => { + it('records request + response with envelope code, status, duration and requestId', async () => { + vi.stubGlobal('fetch', vi.fn(async () => okEnvelope({ id: 'ses_1' }))); + const http = new DaemonHttpClient('http://example.test:7878'); + + await http.post('/sessions', { metadata: { cwd: '/repo' } }); + + const entries = traceEntries(); + const request = entries.find((e) => e.kind === 'rest:request'); + const response = entries.find((e) => e.kind === 'rest:response'); + expect(request).toBeDefined(); + expect(request!.method).toBe('POST'); + expect(request!.path).toBe('/sessions'); + expect(request!.requestId).toMatch(/./); + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.code).toBe(0); + expect(typeof response!.durationMs).toBe('number'); + expect(response!.requestId).toBe(request!.requestId); + const detail = response!.detail as { envelope: { request_id: string } }; + expect(detail.envelope.request_id).toBe('req_env_1'); + }); + + it('redacts sensitive request fields (api_key / authorization)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => okEnvelope({}))); + const http = new DaemonHttpClient('http://example.test:7878'); + + await http.post('/providers', { api_key: 'YOUR_API_KEY', authorization: 'Bearer x' }); + + const request = traceEntries().find((e) => e.kind === 'rest:request'); + const body = (request!.detail as { body: Record }).body; + expect(body['api_key']).toBe('[redacted]'); + expect(body['authorization']).toBe('[redacted]'); + }); + + it('records a daemon API error (non-zero envelope code) as rest:error', async () => { + vi.stubGlobal('fetch', vi.fn(async () => errEnvelope(40401, 'session does not exist'))); + const http = new DaemonHttpClient('http://example.test:7878'); + + await expect(http.get('/sessions/ses_x')).rejects.toThrow(); + + const entry = traceEntries().find((e) => e.kind === 'rest:error'); + expect(entry).toBeDefined(); + expect(entry!.code).toBe(40401); + expect(entry!.label).toContain('session does not exist'); + }); + + it('records a network failure with its phase', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Promise.reject(new TypeError('Failed to fetch')))); + const http = new DaemonHttpClient('http://example.test:7878'); + + await expect(http.get('/healthz')).rejects.toThrow(); + + const entry = traceEntries().find((e) => e.kind === 'rest:error'); + expect(entry).toBeDefined(); + expect((entry!.detail as { phase: string }).phase).toBe('fetch'); + }); + + it('records a JSON parse failure with HTTP status', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('busy', { status: 502 }))); + const http = new DaemonHttpClient('http://example.test:7878'); + + await expect(http.get('/healthz')).rejects.toThrow(); + + const entry = traceEntries().find((e) => e.kind === 'rest:error'); + expect(entry).toBeDefined(); + expect(entry!.status).toBe(502); + expect((entry!.detail as { phase: string }).phase).toBe('parse'); + }); +}); + +describe('WS tracing via DaemonEventSocket', () => { + class FakeWebSocket { + static OPEN = 1; + static last: FakeWebSocket | null = null; + onopen: (() => void) | null = null; + onmessage: ((ev: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((ev?: { code: number; reason: string; wasClean: boolean }) => void) | null = null; + readyState = 1; + sent: string[] = []; + constructor(public url: string) { + FakeWebSocket.last = this; + } + send(data: string): void { + this.sent.push(data); + } + close(): void {} + } + + const handlers: DaemonEventSocketHandlers = { + onWireEvent: () => {}, + onRawAgentEvent: () => {}, + onResync: () => {}, + onConnectionState: () => {}, + onError: () => {}, + }; + + it('records lifecycle, handshake frames and event frames with session/seq/offset', () => { + vi.stubGlobal('WebSocket', FakeWebSocket); + const socket = new DaemonEventSocket('ws://example.test/ws', 'client_1', handlers); + socket.subscribe('ses_1', { seq: 0 }); + socket.connect(); + const fake = FakeWebSocket.last!; + fake.onopen?.(); + fake.onmessage?.({ data: JSON.stringify({ type: 'server_hello', payload: {} }) }); + fake.onmessage?.({ + data: JSON.stringify({ + type: 'message.delta', + session_id: 'ses_1', + seq: 7, + offset: 3, + timestamp: '2026-06-12T00:00:00Z', + payload: { delta: 'hi' }, + }), + }); + fake.onclose?.({ code: 1006, reason: 'gone', wasClean: false }); + socket.close(); + + const entries = traceEntries(); + const kinds = entries.map((e) => `${e.kind}:${e.eventType ?? ''}`); + expect(kinds).toContain('ws:lifecycle:connect'); + expect(kinds).toContain('ws:lifecycle:open'); + expect(kinds).toContain('ws:in:server_hello'); + expect(kinds).toContain('ws:out:client_hello'); + expect(kinds).toContain('ws:lifecycle:close'); + expect(kinds).toContain('ws:lifecycle:reconnect-scheduled'); + + const event = entries.find((e) => e.eventType === 'message.delta'); + expect(event).toBeDefined(); + expect(event!.sessionId).toBe('ses_1'); + expect(event!.seq).toBe(7); + expect(event!.offset).toBe(3); + + const hello = entries.find((e) => e.kind === 'ws:out' && e.eventType === 'client_hello'); + const helloDetail = hello!.detail as { payload: { subscriptions: string[] } }; + expect(helloDetail.payload.subscriptions).toContain('ses_1'); + }); +}); + +describe('sanitization + buffer bounds + export', () => { + it('truncates long strings and elides base64-like blobs', () => { + const long = 'lorem ipsum '.repeat(200); // 2400 chars, with spaces (not base64-like) + const b64 = 'A'.repeat(300); + const out = sanitizeForTrace({ text: long, image: b64 }) as Record; + expect(out['text']!.length).toBeLessThan(600); + expect(out['text']).toContain('[+1900 chars]'); + expect(out['image']).toContain('base64-like'); + }); + + it('keeps at most 1000 entries (ring buffer)', () => { + for (let i = 0; i < 1100; i++) { + traceWsIn({ type: 'ping', payload: { nonce: i } }); + } + expect(traceEntries().length).toBe(1000); + // Oldest entries dropped — the first kept nonce is 100. + const first = traceEntries()[0]!.detail as { nonce: number }; + expect(first.nonce).toBe(100); + }); + + it('exports JSONL that parses back into entries', () => { + traceWsIn({ type: 'ping', payload: { nonce: 1 } }); + const jsonl = traceToJsonl(); + const lines = jsonl.split('\n'); + expect(lines.length).toBe(traceEntries().length); + const parsed = JSON.parse(lines[0]!) as { kind: string }; + expect(parsed.kind).toBe('ws:in'); + }); +});