- MCP secrets: substituteEnvVars recursively resolves {env:NAME} in mcp.json string values from process.env before Zod (opencode-compatible); unset -> '' + boot warning, and invalid-config log names the unset vars (an empty {env:VAR} in a strict url/command field invalidates the whole config)
- data/mcp.json now untracked (.gitignore flips !data/mcp.json -> !data/mcp.example.json); tracked template data/mcp.example.json carries "{env:CONTEXT7_API_KEY}"; .env.example documents the key (9 mcp-config tests)
- Coder fix: message_complete frame model widened string -> string|null (server+web ws-frames parity); dispatcher publishes model: task.model at all 4 external completion points — a null model otherwise fail-closed in publishFrame and dropped the whole frame incl. status:'complete' (regression test)
- Coder fix: claude-sdk mapUserToolResults maps user-message tool_result blocks -> terminal tool_update events (completed/failed w/ output) so tool snapshots resolve instead of spinning forever
- Composer: AgentComposerBar drops §9b resumed/history/new chip + token readout, loses flex-wrap so the row stays one line; CoderPane gains a per-chat localStorage agent-config cache (restores last model on reopen) + threads model into the timeline/chip
- Docs: root CLAUDE.md slimmed (~190 lines), per-app refs split to apps/{coder,server,web}/CLAUDE.md; new docs/coder-backends.md, docs/project-discovery.md, docs/coding-standards/ (cross-app-contract-parity); ARCHITECTURE.md links the backends doc
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
232 lines
7.8 KiB
TypeScript
232 lines
7.8 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('accepts a message_complete frame with a null model (external coder, no model selected)', () => {
|
|
// Regression guard: the dispatcher publishes `model: task.model` (string |
|
|
// null). When null, this MUST validate or publishFrame fail-closes and drops
|
|
// the whole frame, incl. the status:'complete' transition.
|
|
const result = WsFrameSchema.safeParse({
|
|
type: 'message_complete',
|
|
message_id: VALID_UUID_A,
|
|
chat_id: VALID_UUID_B,
|
|
model: null,
|
|
});
|
|
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();
|
|
});
|
|
});
|