/** * Chat seam tests — verify request/response type contracts * * Validates that TypeScript types match the Rust serde-serialized format. * Uses round-trip JSON serialization to catch field name mismatches * (e.g., if Rust changes `rename_all = "camelCase"` or adds/removes fields). */ import { describe, it, expect } from 'vitest'; // --------------------------------------------------------------------------- // Rust side: StreamChatRequest (camelCase via serde rename_all) // --------------------------------------------------------------------------- interface StreamChatRequest { agentId: string; sessionId: string; message: string; thinkingEnabled?: boolean; reasoningEffort?: string; planMode?: boolean; subagentEnabled?: boolean; model?: string; } interface ChatRequest { agentId: string; message: string; thinkingEnabled?: boolean; reasoningEffort?: string; planMode?: boolean; subagentEnabled?: boolean; model?: string; } interface ChatResponse { content: string; inputTokens: number; outputTokens: number; } // --------------------------------------------------------------------------- // Rust side: StreamChatEvent (tagged union, tag = "type") // --------------------------------------------------------------------------- type StreamChatEvent = | { type: 'delta'; delta: string } | { type: 'thinkingDelta'; delta: string } | { type: 'toolStart'; name: string; input: unknown } | { type: 'toolEnd'; name: string; output: unknown } | { type: 'subtaskStatus'; taskId: string; description: string; status: string; detail?: string } | { type: 'iterationStart'; iteration: number; maxIterations: number } | { type: 'handStart'; name: string; params: unknown } | { type: 'handEnd'; name: string; result: unknown } | { type: 'complete'; inputTokens: number; outputTokens: number } | { type: 'error'; message: string }; // --------------------------------------------------------------------------- // Simulated Rust serde output — these strings represent what Rust would emit. // If a field name changes in Rust, the JSON.parse round-trip will fail here. // --------------------------------------------------------------------------- const RUST_STREAM_CHAT_REQUEST = `{ "agentId": "agent-1", "sessionId": "sess-abc", "message": "Hello", "thinkingEnabled": true, "reasoningEffort": "high", "planMode": false, "subagentEnabled": true, "model": "gpt-4o" }`; const RUST_CHAT_RESPONSE = `{ "content": "Hello back!", "inputTokens": 10, "outputTokens": 5 }`; const RUST_EVENT_DELTA = `{"type":"delta","delta":"Hello world"}`; const RUST_EVENT_THINKING = `{"type":"thinkingDelta","delta":"thinking..."}`; const RUST_EVENT_TOOL_START = `{"type":"toolStart","name":"web_search","input":{"query":"test"}}`; const RUST_EVENT_TOOL_END = `{"type":"toolEnd","name":"web_search","output":{"results":[]}}`; const RUST_EVENT_HAND_START = `{"type":"handStart","name":"hand_quiz","params":{"topic":"math"}}`; const RUST_EVENT_HAND_END = `{"type":"handEnd","name":"hand_quiz","result":{"questions":[]}}`; const RUST_EVENT_SUBTASK = `{"type":"subtaskStatus","taskId":"t1","description":"Research","status":"running","detail":"Searching"}`; const RUST_EVENT_ITERATION = `{"type":"iterationStart","iteration":2,"maxIterations":10}`; const RUST_EVENT_COMPLETE = `{"type":"complete","inputTokens":100,"outputTokens":50}`; const RUST_EVENT_ERROR = `{"type":"error","message":"已取消"}`; describe('Chat Seam: request format contract (JSON round-trip)', () => { it('StreamChatRequest parses from simulated Rust output', () => { const req: StreamChatRequest = JSON.parse(RUST_STREAM_CHAT_REQUEST); expect(req.agentId).toBe('agent-1'); expect(req.sessionId).toBe('sess-abc'); expect(req.message).toBe('Hello'); expect(req.thinkingEnabled).toBe(true); expect(req.reasoningEffort).toBe('high'); expect(req.planMode).toBe(false); expect(req.subagentEnabled).toBe(true); expect(req.model).toBe('gpt-4o'); }); it('StreamChatRequest optional fields are camelCase', () => { const req: StreamChatRequest = { agentId: 'a', sessionId: 's', message: 'm', thinkingEnabled: true, reasoningEffort: 'high', planMode: false, subagentEnabled: true, model: 'gpt-4o', }; // Verify camelCase naming by serializing and checking no snake_case const json = JSON.stringify(req); expect(json).not.toContain('thinking_enabled'); expect(json).not.toContain('reasoning_effort'); expect(json).not.toContain('plan_mode'); expect(json).not.toContain('subagent_enabled'); }); it('ChatResponse parses from simulated Rust output', () => { const resp: ChatResponse = JSON.parse(RUST_CHAT_RESPONSE); expect(resp.content).toBe('Hello back!'); expect(resp.inputTokens).toBe(10); expect(resp.outputTokens).toBe(5); }); }); describe('Chat Seam: StreamChatEvent format contract (JSON round-trip)', () => { it('delta event parses from simulated Rust output', () => { const event: StreamChatEvent = JSON.parse(RUST_EVENT_DELTA); expect(event.type).toBe('delta'); if (event.type === 'delta') { expect(event.delta).toBe('Hello world'); } }); it('thinkingDelta event parses correctly', () => { const event: StreamChatEvent = JSON.parse(RUST_EVENT_THINKING); expect(event.type).toBe('thinkingDelta'); if (event.type === 'thinkingDelta') { expect(event.delta).toBe('thinking...'); } }); it('toolStart/toolEnd events parse with correct fields', () => { const start: StreamChatEvent = JSON.parse(RUST_EVENT_TOOL_START); const end: StreamChatEvent = JSON.parse(RUST_EVENT_TOOL_END); if (start.type === 'toolStart') { expect(start.name).toBe('web_search'); expect(start.input).toBeDefined(); } if (end.type === 'toolEnd') { expect(end.name).toBe('web_search'); expect(end.output).toBeDefined(); } }); it('handStart/handEnd events have correct structure', () => { const start: StreamChatEvent = JSON.parse(RUST_EVENT_HAND_START); const end: StreamChatEvent = JSON.parse(RUST_EVENT_HAND_END); if (start.type === 'handStart') { expect(start.name).toMatch(/^hand_/); expect(start.params).toBeDefined(); } if (end.type === 'handEnd') { expect(end.name).toMatch(/^hand_/); expect(end.result).toBeDefined(); } }); it('subtaskStatus event parses all fields including optional detail', () => { const event: StreamChatEvent = JSON.parse(RUST_EVENT_SUBTASK); if (event.type === 'subtaskStatus') { expect(event.taskId).toBe('t1'); expect(event.description).toBe('Research'); expect(event.status).toBe('running'); expect(event.detail).toBe('Searching'); } }); it('iterationStart event parses iteration and maxIterations', () => { const event: StreamChatEvent = JSON.parse(RUST_EVENT_ITERATION); if (event.type === 'iterationStart') { expect(event.iteration).toBe(2); expect(event.maxIterations).toBe(10); } }); it('complete event has token counts', () => { const event: StreamChatEvent = JSON.parse(RUST_EVENT_COMPLETE); if (event.type === 'complete') { expect(event.inputTokens).toBeGreaterThanOrEqual(0); expect(event.outputTokens).toBeGreaterThanOrEqual(0); } }); it('error event has message field', () => { const event: StreamChatEvent = JSON.parse(RUST_EVENT_ERROR); if (event.type === 'error') { expect(event.message).toBeTruthy(); } }); it('all 10 StreamChatEvent variants are represented in Rust output', () => { const variants = [ RUST_EVENT_DELTA, RUST_EVENT_THINKING, RUST_EVENT_TOOL_START, RUST_EVENT_TOOL_END, RUST_EVENT_SUBTASK, RUST_EVENT_ITERATION, RUST_EVENT_HAND_START, RUST_EVENT_HAND_END, RUST_EVENT_COMPLETE, RUST_EVENT_ERROR, ]; const types = variants.map(v => JSON.parse(v).type); expect(types).toEqual([ 'delta', 'thinkingDelta', 'toolStart', 'toolEnd', 'subtaskStatus', 'iterationStart', 'handStart', 'handEnd', 'complete', 'error', ]); }); });