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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user