feat(web): add typed SPA chat

Bring the chat experience into the Vite/React-Router SPA on the exact typed
Socket.IO /chat contract from @mosaicstack/types, replacing the /chat
placeholder behind AuthGuard. Surfaces message:ack (with an accessible
status), agent:start, streamed agent:text/agent:thinking, tool start/end
status, agent:end with usage, session:info (thinking controls + routing
decision), commands:manifest, command:result, command:approval (with a
one-time approved-run affordance), system:reload (refreshing the rendered
manifest), and error, and emits message/abort/set:thinking/command:execute/
command:approve with exact payloads.

The gateway does not guarantee message:ack is the first event for a new
conversation (session:info, and error on auth/session-creation failure, can
both arrive first) — conversation-scoped events now adopt the conversation
from whichever scoped event names it first while a send is pending, then
filter everything else against that established conversation. A typed error
stops streaming instead of leaving Stop stuck active; agent:end no longer
appends an empty assistant turn when there is no text or thinking; and a
second message can no longer be sent while a turn is streaming.

Command approval is now integrity-checked end to end: only one
command:approve request may be outstanding at a time (a concurrent request
is ignored rather than overwriting the pending command/args), a stale or
mismatched command:approval response cannot replace active approval state,
and running an approved command clears its approval state immediately (via
a ref, before React re-renders) so a double-click cannot replay
command:execute.

The `/chat` socket is now typed at a single boundary: apps/web/src/lib/
socket.ts narrows socket.io-client's untyped `io()` return value to
`ChatSocket` (Socket<ServerToClientEvents, ClientToServerEvents>) once, at
creation, via the one assertion the library's types force; every consumer
(use-chat-connection.ts) then gets fully checked `on`/`emit` calls with no
further casts. The shared contract types live in the new
apps/web/src/lib/chat-contract.ts (replacing the old spa/chat/types.ts
shim), which re-exports them via type-only imports resolved directly
against packages/types/src (apps/web has no @mosaicstack/types package
dependency, so this stays source-only and is erased at compile time —
no package manifest or lockfile is touched). The two recorded-event test
suites now drive a shared, typed fake socket
(spa/chat/test-support/fake-chat-socket.ts) instead of an untyped
`(event: string, payload: unknown)` harness, so a wrong event name or
malformed payload fails to compile.
This commit is contained in:
shaggy (mosaic-dev box)
2026-08-09 21:39:16 -05:00
parent 87daa12976
commit b2e005f2b4
14 changed files with 2093 additions and 5 deletions
@@ -0,0 +1,607 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFakeChatSocket } from './test-support/fake-chat-socket';
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
getSocketMock: vi.fn(),
destroySocketMock: vi.fn(),
}));
vi.mock('@/lib/socket', () => ({
getSocket: getSocketMock,
destroySocket: destroySocketMock,
}));
import { useChatConnection, type ChatConnectionValue } from './use-chat-connection';
let fake: ReturnType<typeof createFakeChatSocket>;
let latest: ChatConnectionValue | null;
let root: Root | null;
let container: HTMLElement | null;
function Harness(): null {
latest = useChatConnection();
return null;
}
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
beforeEach(async () => {
fake = createFakeChatSocket();
getSocketMock.mockReset().mockReturnValue(fake.socket);
destroySocketMock.mockReset();
latest = null;
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<Harness />);
});
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
container = null;
});
describe('useChatConnection', () => {
it('establishes the active conversation from the first message:ack when message omitted conversationId', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
expect(latest?.state.conversationId).toBe('c1');
expect(latest?.state.ack).toEqual({ conversationId: 'c1', messageId: 'm1' });
});
it('ignores a message:ack for a different conversation once one is already active', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c2', messageId: 'm2' });
});
expect(latest?.state.conversationId).toBe('c1');
expect(latest?.state.ack).toEqual({ conversationId: 'c1', messageId: 'm1' });
});
it('accumulates streamed agent:text chunks in order for the active conversation', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
await act(async () => {
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo' });
});
expect(latest?.state.streaming).toBe(true);
expect(latest?.state.text).toBe('Hello');
});
it('filters out agent:text events for a conversation that is not active', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hi' });
});
await act(async () => {
fake.serverEmit('agent:text', { conversationId: 'other', text: 'nope' });
});
expect(latest?.state.text).toBe('Hi');
});
it('accumulates streamed agent:thinking text for the active conversation', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
await act(async () => {
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'step one. ' });
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'step two.' });
});
expect(latest?.state.thinking).toBe('step one. step two.');
});
it('tracks a tool call from start through a successful end', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
await act(async () => {
fake.serverEmit('agent:tool:start', {
conversationId: 'c1',
toolCallId: 't1',
toolName: 'search',
});
});
expect(latest?.state.tools).toEqual([
{ toolCallId: 't1', toolName: 'search', status: 'running' },
]);
await act(async () => {
fake.serverEmit('agent:tool:end', {
conversationId: 'c1',
toolCallId: 't1',
toolName: 'search',
isError: false,
});
});
expect(latest?.state.tools).toEqual([
{ toolCallId: 't1', toolName: 'search', status: 'success' },
]);
});
it('tracks a tool call that ends in error', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmit('agent:tool:start', {
conversationId: 'c1',
toolCallId: 't1',
toolName: 'shell',
});
});
await act(async () => {
fake.serverEmit('agent:tool:end', {
conversationId: 'c1',
toolCallId: 't1',
toolName: 'shell',
isError: true,
});
});
expect(latest?.state.tools).toEqual([{ toolCallId: 't1', toolName: 'shell', status: 'error' }]);
});
it('finalizes the streamed response into the transcript and captures usage on agent:end', async () => {
const usage = {
provider: 'anthropic',
modelId: 'claude',
thinkingLevel: 'medium',
tokens: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, total: 30 },
cost: 0.01,
context: { percent: 5, window: 200000 },
};
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hello there' });
});
await act(async () => {
fake.serverEmit('agent:end', { conversationId: 'c1', usage });
});
expect(latest?.state.streaming).toBe(false);
expect(latest?.state.text).toBe('');
expect(latest?.state.usage).toEqual(usage);
expect(latest?.state.messages.at(-1)).toMatchObject({ role: 'assistant', text: 'Hello there' });
});
it('records session:info including thinking controls and routing decision', async () => {
const sessionInfo = {
conversationId: 'c1',
provider: 'anthropic',
modelId: 'claude',
thinkingLevel: 'medium',
availableThinkingLevels: ['low', 'medium', 'high'],
routingDecision: {
model: 'claude',
provider: 'anthropic',
ruleName: 'default',
reason: 'default route',
},
};
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
fake.serverEmit('session:info', sessionInfo);
});
expect(latest?.state.sessionInfo).toEqual(sessionInfo);
});
it('records the commands manifest regardless of active conversation', async () => {
const manifest = {
commands: [],
skills: [],
version: 1,
};
await act(async () => {
fake.serverEmit('commands:manifest', { manifest });
});
expect(latest?.state.manifest).toEqual(manifest);
});
it('records a system:reload broadcast regardless of active conversation', async () => {
const reload = {
commands: [],
skills: [],
providers: ['anthropic'],
message: 'Commands reloaded',
};
await act(async () => {
fake.serverEmit('system:reload', reload);
});
expect(latest?.state.systemReload).toEqual(reload);
});
it('surfaces an error for the active conversation and filters one for another conversation', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
fake.serverEmit('error', { conversationId: 'other', error: 'ignored' });
});
expect(latest?.state.error).toBe(null);
await act(async () => {
fake.serverEmit('error', { conversationId: 'c1', error: 'boom' });
});
expect(latest?.state.error).toBe('boom');
});
it('records a failed command:result for the active conversation', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
fake.serverEmit('command:result', {
conversationId: 'c1',
command: 'model',
success: false,
message: 'unknown model',
});
});
expect(latest?.state.commandResults).toEqual([
{ conversationId: 'c1', command: 'model', success: false, message: 'unknown model' },
]);
});
it('pairs a successful command:approval with the pending request so it can be run with its approvalId', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
});
expect(fake.emitted).toContainEqual({
event: 'command:approve',
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
});
await act(async () => {
fake.serverEmit('command:approval', {
conversationId: 'c1',
command: 'deploy',
success: true,
approvalId: 'ap1',
expiresAt: '2026-01-01T00:00:00.000Z',
});
});
expect(latest?.state.approval?.approvalId).toBe('ap1');
await act(async () => {
latest?.actions.runApprovedCommand();
});
expect(fake.emitted).toContainEqual({
event: 'command:execute',
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
});
});
it('sendMessage emits optional conversationId/provider/modelId and appends an optimistic user turn', async () => {
await act(async () => {
latest?.actions.sendMessage({ content: 'hello', provider: 'anthropic', modelId: 'claude' });
});
expect(fake.emitted).toContainEqual({
event: 'message',
payload: {
conversationId: undefined,
content: 'hello',
provider: 'anthropic',
modelId: 'claude',
},
});
expect(latest?.state.messages.at(-1)).toMatchObject({ role: 'user', text: 'hello' });
});
it('sendMessage includes the active conversationId once established', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
latest?.actions.sendMessage({ content: 'again' });
});
expect(fake.emitted).toContainEqual({
event: 'message',
payload: { conversationId: 'c1', content: 'again', provider: undefined, modelId: undefined },
});
});
it('abort emits abort with the active conversationId', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
latest?.actions.abort();
});
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
});
it('setThinking emits set:thinking with the requested level and active conversationId', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
latest?.actions.setThinking('high');
});
expect(fake.emitted).toContainEqual({
event: 'set:thinking',
payload: { conversationId: 'c1', level: 'high' },
});
});
it('executeCommand emits command:execute with the exact command payload', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
latest?.actions.executeCommand({ command: 'model', args: 'gpt-5' });
});
expect(fake.emitted).toContainEqual({
event: 'command:execute',
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
});
});
it('adopts session:info as the active conversation when it arrives before message:ack, preserving it across the later ack', async () => {
await act(async () => {
latest?.actions.sendMessage({ content: 'hi' });
});
await act(async () => {
fake.serverEmit('session:info', {
conversationId: 'c1',
provider: 'anthropic',
modelId: 'claude',
thinkingLevel: 'medium',
availableThinkingLevels: ['low', 'medium', 'high'],
});
});
expect(latest?.state.conversationId).toBe('c1');
expect(latest?.state.sessionInfo?.provider).toBe('anthropic');
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
expect(latest?.state.ack).toEqual({ conversationId: 'c1', messageId: 'm1' });
expect(latest?.state.sessionInfo?.provider).toBe('anthropic');
});
it('adopts a pre-ack error as the active conversation, surfaces it, and does not leave streaming stuck', async () => {
await act(async () => {
latest?.actions.sendMessage({ content: 'hi' });
});
await act(async () => {
fake.serverEmit('error', {
conversationId: 'c1',
error: 'Failed to start agent session. Please try again.',
});
});
expect(latest?.state.conversationId).toBe('c1');
expect(latest?.state.error).toBe('Failed to start agent session. Please try again.');
expect(latest?.state.streaming).toBe(false);
});
it('stops streaming when a typed error arrives mid-turn', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
expect(latest?.state.streaming).toBe(true);
await act(async () => {
fake.serverEmit('error', { conversationId: 'c1', error: 'boom' });
});
expect(latest?.state.streaming).toBe(false);
});
it('does not append an assistant message on agent:end when there is no text or thinking', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
const before = latest?.state.messages.length ?? 0;
await act(async () => {
fake.serverEmit('agent:end', { conversationId: 'c1' });
});
expect(latest?.state.messages.length).toBe(before);
expect(latest?.state.streaming).toBe(false);
});
it('ignores a second approval request while the first is still outstanding, preserving the original command and args', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
});
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy', args: 'staging' });
});
expect(latest?.state.pendingApproval).toEqual({ command: 'deploy', args: 'prod' });
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
await act(async () => {
fake.serverEmit('command:approval', {
conversationId: 'c1',
command: 'deploy',
success: true,
approvalId: 'ap1',
expiresAt: '2026-01-01T00:00:00.000Z',
});
});
await act(async () => {
latest?.actions.runApprovedCommand();
});
expect(fake.emitted).toContainEqual({
event: 'command:execute',
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
});
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
});
it('ignores a stale command:approval response that does not match the pending request', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
});
await act(async () => {
fake.serverEmit('command:approval', {
conversationId: 'c1',
command: 'rollback',
success: true,
approvalId: 'stale',
expiresAt: '2026-01-01T00:00:00.000Z',
});
});
expect(latest?.state.approval).toBeNull();
});
it('emits command:execute only once even when runApprovedCommand is invoked twice back to back', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
});
await act(async () => {
fake.serverEmit('command:approval', {
conversationId: 'c1',
command: 'deploy',
success: true,
approvalId: 'ap1',
expiresAt: '2026-01-01T00:00:00.000Z',
});
});
await act(async () => {
latest?.actions.runApprovedCommand();
latest?.actions.runApprovedCommand();
});
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
expect(latest?.state.approval).toBeNull();
expect(latest?.state.pendingApproval).toBeNull();
});
it('ignores sendMessage while a turn is streaming', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
await act(async () => {
latest?.actions.sendMessage({ content: 'too soon' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(0);
expect(latest?.state.messages.some((m) => m.text === 'too soon')).toBe(false);
});
it('refreshes the manifest commands from a system:reload broadcast', async () => {
await act(async () => {
fake.serverEmit('commands:manifest', {
manifest: {
commands: [
{
name: 'model',
aliases: [],
description: 'old',
scope: 'core',
execution: 'socket',
available: true,
},
],
skills: [],
version: 1,
},
});
});
await act(async () => {
fake.serverEmit('system:reload', {
commands: [
{
name: 'deploy',
aliases: [],
description: 'new',
scope: 'core',
execution: 'socket',
available: true,
},
],
skills: [],
providers: ['anthropic'],
message: 'Commands reloaded',
});
});
expect(latest?.state.manifest?.commands.map((c) => c.name)).toEqual(['deploy']);
});
it('removes every listener and tears down the socket on cleanup, using no network', async () => {
const registeredEvents = [...fake.listeners.keys()];
expect(registeredEvents.length).toBeGreaterThan(0);
await act(async () => {
root?.unmount();
});
root = null;
for (const [, handlers] of fake.listeners) {
expect(handlers.size).toBe(0);
}
expect(destroySocketMock).toHaveBeenCalledOnce();
});
});