First half of the WebSocket-frame-typing batch (split per recon — total
scope was ~535 LoC, larger than the roadmap's ~300 estimate, so the
server-side publish-site conversion lands separately in v1.13.11-b).
Phase A scope:
(1) apps/server/src/types/ws-frames.ts (NEW) — Zod schemas for all 27
wire-format WS frame types. Discriminated union (WsFrameSchema) plus
KNOWN_FRAME_TYPES const for diagnostic lookup. UUIDs are z.string().
uuid(); model-emitted tool_call_id stays z.string().min(1) since OpenAI-
compatible APIs emit "call_<random>" not UUID. Per-kind payload narrowing
(tool args, message_parts payloads) intentionally stays z.unknown() —
frame-level drift detection is the goal; deep payload validation is
follow-up work.
(2) apps/web/src/api/ws-frames.ts (NEW) — byte-identical mirror of the
authoritative server file. No path alias from web→server in the existing
tsconfig setup; sync-by-hand was chosen over a new packages/shared/ dir.
A ws-frames.test.ts test asserts the two files match.
(3) apps/server/src/services/broker.ts — adds publishFrame() and
publishUserFrame() methods to the Broker interface. Both validate via
WsFrameSchema and fail-closed: log + drop on invalid. createBroker now
accepts an optional FastifyBaseLogger so validation failures land in
the pino stream (with console.error fallback for unit tests). The
existing publish() / publishUser() raw methods stay legal — they get
converted to the typed variants in v1.13.11-b.
(4) apps/web/src/hooks/useSessionStream.ts + useUserEvents.ts — wrap
ws.onmessage with WsFrameSchema.safeParse. Fail-closed: invalid frames
log + return without dispatching. Hand-maintained WsFrame and
SessionEvent types stay in place; one cast bridges Zod-typed → narrowed
shape (Zod uses OpaqueObject for nested Message[] / WorkspacePane[] etc.,
which are dev-time-narrowed via the existing hand-maintained types).
(5) apps/web/package.json — adds zod ^3.23.8 as a direct dep. Was a
transitive dep via ai-sdk / postgres; promotion makes the import legal.
(6) Tests: 15 new in ws-frames.test.ts covering happy-path per major
frame type, drift-catchers (unknown type, invalid enum, non-UUID, negative
tokens), parts-authoritative read variants, the mirror-file diff check,
and four broker fail-closed scenarios. 219/219 server tests pass (was
204; +15 new).
Two recon corrections to the dispatch brief, both flagged before
implementation:
- No 'parts_appended' frame exists. The brief assumed one; the codebase
reads parts via the messages_with_parts view after message_complete
triggers a refetch. MessagePartSchema is therefore unused this batch.
- No 'tool_running' frame exists. The brief listed it as standalone; it
is in fact a 'chat_status' variant ({ status: 'tool_running' }), already
covered by ChatStatusFrame.
Smoke: clean container boot, no validation errors in the server log. Real
production frames pass validation (the schemas were derived from the
existing hand-maintained types in api/types.ts and sessionEvents.ts).
v1.13.11-b will follow immediately: convert all ~85 raw broker.publish /
ctx.publish call sites across 11 server files to publishFrame /
publishUserFrame. Mechanical edit; the wiring done here means the diff
in -b is just the call-site swaps.
~310 LoC across 9 files (4 new + 5 modified).
219 lines
7.2 KiB
TypeScript
219 lines
7.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
WsFrameSchema,
|
|
KNOWN_FRAME_TYPES,
|
|
type WsFrame,
|
|
} from '../../types/ws-frames.js';
|
|
import { createBroker } from '../broker.js';
|
|
|
|
const VALID_UUID_A = '00000000-0000-0000-0000-000000000001';
|
|
const VALID_UUID_B = '00000000-0000-0000-0000-000000000002';
|
|
const VALID_UUID_C = '00000000-0000-0000-0000-000000000003';
|
|
const VALID_TIMESTAMP = '2026-05-22T14:30:00.000Z';
|
|
|
|
describe('WsFrameSchema (v1.13.11-a)', () => {
|
|
it('accepts a well-formed chat_status frame', () => {
|
|
const result = WsFrameSchema.safeParse({
|
|
type: 'chat_status',
|
|
chat_id: VALID_UUID_A,
|
|
status: 'streaming',
|
|
at: VALID_TIMESTAMP,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('rejects an unknown frame type', () => {
|
|
const result = WsFrameSchema.safeParse({
|
|
type: 'cosmic_ray_strike',
|
|
chat_id: VALID_UUID_A,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects a chat_status frame with invalid status enum', () => {
|
|
// v1.12.1 dropped the legacy 'working' status. Any frame still emitting it
|
|
// should fail validation — that's a drift catcher.
|
|
const result = WsFrameSchema.safeParse({
|
|
type: 'chat_status',
|
|
chat_id: VALID_UUID_A,
|
|
status: 'working',
|
|
at: VALID_TIMESTAMP,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects a UUID field with a non-UUID string', () => {
|
|
const result = WsFrameSchema.safeParse({
|
|
type: 'chat_status',
|
|
chat_id: 'not-a-uuid',
|
|
status: 'idle',
|
|
at: VALID_TIMESTAMP,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects negative token counts in usage frame', () => {
|
|
const result = WsFrameSchema.safeParse({
|
|
type: 'usage',
|
|
message_id: VALID_UUID_A,
|
|
chat_id: VALID_UUID_B,
|
|
completion_tokens: -1,
|
|
ctx_used: 100,
|
|
ctx_max: 1000,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('accepts a usage frame with nullable token counts (pre-v1.13.7 history)', () => {
|
|
const result = WsFrameSchema.safeParse({
|
|
type: 'usage',
|
|
message_id: VALID_UUID_A,
|
|
chat_id: VALID_UUID_B,
|
|
completion_tokens: null,
|
|
ctx_used: null,
|
|
ctx_max: null,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('accepts a tool_result frame with non-UUID tool_call_id (model-emitted)', () => {
|
|
// Model-emitted tool_call_ids look like "call_abc123", not UUIDs.
|
|
const result = WsFrameSchema.safeParse({
|
|
type: 'tool_result',
|
|
tool_message_id: VALID_UUID_A,
|
|
chat_id: VALID_UUID_B,
|
|
tool_call_id: 'call_abc123',
|
|
output: { whatever: true },
|
|
truncated: false,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('accepts a compacted frame', () => {
|
|
const result = WsFrameSchema.safeParse({
|
|
type: 'compacted',
|
|
session_id: VALID_UUID_A,
|
|
chat_id: VALID_UUID_B,
|
|
summary_message_id: VALID_UUID_C,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('accepts a session_workspace_updated frame', () => {
|
|
const result = WsFrameSchema.safeParse({
|
|
type: 'session_workspace_updated',
|
|
session_id: VALID_UUID_A,
|
|
workspace_panes: [{ id: 'p1', kind: 'chat', chatIds: [], activeChatIdx: 0 }],
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('every KNOWN_FRAME_TYPES entry has a discriminated branch', () => {
|
|
// Probe each known type by attempting a minimal valid construction.
|
|
// Failure here means the union and the KNOWN_FRAME_TYPES list drifted.
|
|
for (const type of KNOWN_FRAME_TYPES) {
|
|
const probe = WsFrameSchema.safeParse({ type, __dummy__: true });
|
|
// We expect FAILURE on every type because we're missing required fields,
|
|
// but the failure must be ABOUT the missing fields, not about an unknown
|
|
// type. A "Invalid discriminator value" error means the type isn't in
|
|
// the union — that's a drift.
|
|
if (probe.success) continue;
|
|
const issues = probe.error.issues;
|
|
const hasInvalidDiscriminator = issues.some(
|
|
(i) => i.code === 'invalid_union_discriminator',
|
|
);
|
|
expect(hasInvalidDiscriminator, `frame type '${type}' is missing from the discriminated union`).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('ws-frames.ts file mirror parity', () => {
|
|
it('apps/server and apps/web copies are byte-identical', () => {
|
|
const here = fileURLToPath(import.meta.url);
|
|
const serverPath = resolve(here, '../../../types/ws-frames.ts');
|
|
const webPath = resolve(here, '../../../../../web/src/api/ws-frames.ts');
|
|
const serverContent = readFileSync(serverPath, 'utf8');
|
|
const webContent = readFileSync(webPath, 'utf8');
|
|
expect(webContent, 'apps/web/src/api/ws-frames.ts must be byte-identical to apps/server/src/types/ws-frames.ts').toBe(serverContent);
|
|
});
|
|
});
|
|
|
|
describe('broker.publishFrame / publishUserFrame fail-closed behavior', () => {
|
|
let logErrors: Array<{ obj: unknown; msg: string }>;
|
|
let mockLog: Parameters<typeof createBroker>[0];
|
|
|
|
beforeEach(() => {
|
|
logErrors = [];
|
|
mockLog = {
|
|
error: (obj: unknown, msg: string) => {
|
|
logErrors.push({ obj, msg });
|
|
},
|
|
info: () => {},
|
|
warn: () => {},
|
|
debug: () => {},
|
|
trace: () => {},
|
|
fatal: () => {},
|
|
child: () => mockLog as never,
|
|
level: 'info',
|
|
silent: () => {},
|
|
} as unknown as Parameters<typeof createBroker>[0];
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('publishFrame delivers a valid frame to subscribers', () => {
|
|
const broker = createBroker(mockLog);
|
|
const received: WsFrame[] = [];
|
|
broker.subscribe('sess-1', (f) => received.push(f as WsFrame));
|
|
broker.publishFrame('sess-1', {
|
|
type: 'delta',
|
|
message_id: VALID_UUID_A,
|
|
chat_id: VALID_UUID_B,
|
|
content: 'hello',
|
|
});
|
|
expect(received).toHaveLength(1);
|
|
expect((received[0] as { type: string }).type).toBe('delta');
|
|
expect(logErrors).toHaveLength(0);
|
|
});
|
|
|
|
it('publishFrame drops + logs an invalid frame instead of delivering it', () => {
|
|
const broker = createBroker(mockLog);
|
|
const received: WsFrame[] = [];
|
|
broker.subscribe('sess-1', (f) => received.push(f as WsFrame));
|
|
broker.publishFrame('sess-1', {
|
|
type: 'delta',
|
|
message_id: 'not-a-uuid',
|
|
content: 'hello',
|
|
} as never);
|
|
expect(received).toHaveLength(0);
|
|
expect(logErrors).toHaveLength(1);
|
|
expect(logErrors[0]!.msg).toMatch(/ws-frame-validation-failed/);
|
|
});
|
|
|
|
it('publishUserFrame drops + logs an invalid user-channel frame', () => {
|
|
const broker = createBroker(mockLog);
|
|
const received: WsFrame[] = [];
|
|
broker.subscribeUser('default', (f) => received.push(f as WsFrame));
|
|
broker.publishUserFrame('default', {
|
|
type: 'chat_status',
|
|
chat_id: VALID_UUID_A,
|
|
status: 'working', // v1.12.1 dropped this enum value
|
|
at: VALID_TIMESTAMP,
|
|
} as never);
|
|
expect(received).toHaveLength(0);
|
|
expect(logErrors).toHaveLength(1);
|
|
});
|
|
|
|
it('publishFrame validation failure does not throw (no cascade into stream-phase)', () => {
|
|
const broker = createBroker(mockLog);
|
|
expect(() =>
|
|
broker.publishFrame('sess-1', { type: 'unknown_type' } as never),
|
|
).not.toThrow();
|
|
});
|
|
});
|