Files
stack/apps/web/src/spa/chat/use-chat-connection.spec.tsx
T

1669 lines
60 KiB
TypeScript

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, type EmittedEvent } from './test-support/fake-chat-socket';
import {
MAX_COMMAND_RESULTS,
MAX_EXECUTED_APPROVAL_IDS,
MAX_MANIFEST_ITEMS,
MAX_MESSAGES,
MAX_STREAM_CHARS,
MAX_TOOLS,
} from './limits';
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('bounds an oversized command:result message to a fixed cap before it ever reaches state', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const hostileMessage = 'x'.repeat(50_000);
await act(async () => {
fake.serverEmit('command:result', {
conversationId: 'c1',
command: 'model',
success: false,
message: hostileMessage,
});
});
const stored = latest?.state.commandResults.at(-1)?.message ?? '';
expect(stored.length).toBeLessThan(hostileMessage.length);
expect(stored.length).toBeLessThanOrEqual(1000);
});
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);
expect(latest?.state.sending).toBe(false);
await act(async () => {
latest?.actions.sendMessage({ content: 'retry' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(2);
expect(latest?.state.messages.some((m) => m.text === 'retry')).toBe(true);
});
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('reconnects the same socket instance after a transient disconnect and accepts a subsequent ack/start/text stream', async () => {
await act(async () => {
fake.simulateDisconnect();
});
// Listeners must still be registered — a transient disconnect must not
// tear anything down or force a fresh singleton.
expect(fake.listeners.get('message:ack')?.size).toBeGreaterThan(0);
await act(async () => {
fake.simulateReconnect();
});
expect(fake.socket.connected).toBe(true);
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm2' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'reconnected' });
});
expect(latest?.state.ack).toEqual({ conversationId: 'c1', messageId: 'm2' });
expect(latest?.state.streaming).toBe(true);
expect(latest?.state.text).toBe('reconnected');
expect(fake.listeners.get('message:ack')?.size).toBeGreaterThan(0);
// No new fake singleton was created — getSocket() always resolved to the
// same instance across the disconnect/reconnect cycle.
expect(getSocketMock.mock.results.every((result) => result.value === fake.socket)).toBe(true);
});
it('resets streaming and pending-send/busy state on a mid-stream disconnect', async () => {
await act(async () => {
latest?.actions.sendMessage({ content: 'hi' });
});
expect(latest?.state.sending).toBe(true);
expect(latest?.state.pendingSend).toBe(true);
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
expect(latest?.state.streaming).toBe(true);
expect(latest?.state.pendingSend).toBe(false);
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
});
expect(latest?.state.approvalRequestPending).toBe(true);
await act(async () => {
fake.simulateDisconnect();
});
expect(latest?.state.streaming).toBe(false);
expect(latest?.state.sending).toBe(false);
expect(latest?.state.pendingSend).toBe(false);
expect(latest?.state.approvalRequestPending).toBe(false);
// The send lock must also be released — a subsequent sendMessage after
// reconnect must not be permanently blocked by the interrupted turn.
await act(async () => {
latest?.actions.sendMessage({ content: 'after reconnect' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(2);
expect(fake.emitted.filter((e) => e.event === 'message').at(-1)).toEqual({
event: 'message',
payload: {
conversationId: 'c1',
content: 'after reconnect',
provider: undefined,
modelId: undefined,
},
});
});
it('emits and appends only one turn when sendMessage is called twice before agent:start, and cannot fork a new conversation', async () => {
await act(async () => {
latest?.actions.sendMessage({ content: 'first' });
latest?.actions.sendMessage({ content: 'second' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
expect(latest?.state.messages).toHaveLength(1);
expect(latest?.state.messages[0]).toMatchObject({ role: 'user', text: 'first' });
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
expect(latest?.state.conversationId).toBe('c1');
});
it('appends a terminal anomaly entry when agent:tool:end references a toolCallId that was never started', 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:end', {
conversationId: 'c1',
toolCallId: 'unseen',
toolName: 'search',
isError: false,
});
});
expect(latest?.state.tools).toEqual([
{ toolCallId: 'unseen', toolName: 'search', status: 'anomaly' },
]);
});
it('assigns unique fallback IDs to multiple malformed tool:start events, and a subsequent malformed tool:end always appends a new anomaly without mutating either prior entry', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
await expect(
act(async () => {
fake.serverEmitRaw('agent:tool:start', {
conversationId: 'c1',
toolCallId: { bad: 'object' },
toolName: 'search',
});
fake.serverEmitRaw('agent:tool:start', {
conversationId: 'c1',
toolCallId: '',
toolName: 'shell',
});
}),
).resolves.not.toThrow();
expect(latest?.state.tools).toHaveLength(2);
const [first, second] = latest?.state.tools ?? [];
expect(first?.status).toBe('running');
expect(second?.status).toBe('running');
expect(first?.toolCallId).not.toBe(second?.toolCallId);
await expect(
act(async () => {
fake.serverEmitRaw('agent:tool:end', {
conversationId: 'c1',
toolCallId: null,
toolName: 'unknown',
isError: false,
});
}),
).resolves.not.toThrow();
expect(latest?.state.tools).toHaveLength(3);
// Neither malformed-start entry was mutated by the malformed end.
expect(latest?.state.tools[0]).toEqual(first);
expect(latest?.state.tools[1]).toEqual(second);
// The malformed end always appends its own terminal anomaly rather than
// colliding with (and silently flipping) an earlier fallback ID.
const terminal = latest?.state.tools[2];
expect(terminal?.status).toBe('anomaly');
expect(terminal?.toolCallId).not.toBe(first?.toolCallId);
expect(terminal?.toolCallId).not.toBe(second?.toolCallId);
});
it('updates only the first matching tool when a tool:end references a toolCallId shared by two tool:start entries', 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: 'dup',
toolName: 'search',
});
fake.serverEmit('agent:tool:start', {
conversationId: 'c1',
toolCallId: 'dup',
toolName: 'search',
});
});
await act(async () => {
fake.serverEmit('agent:tool:end', {
conversationId: 'c1',
toolCallId: 'dup',
toolName: 'search',
isError: false,
});
});
expect(latest?.state.tools).toEqual([
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
]);
});
it('caps the tools list at MAX_TOOLS when flooded with tool:start events', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
await act(async () => {
for (let i = 0; i < MAX_TOOLS + 10; i += 1) {
fake.serverEmit('agent:tool:start', {
conversationId: 'c1',
toolCallId: `t${i}`,
toolName: 'search',
});
}
});
expect(latest?.state.tools).toHaveLength(MAX_TOOLS);
// Oldest dropped deterministically — the most recent tool call survives.
expect(latest?.state.tools.at(-1)?.toolCallId).toBe(`t${MAX_TOOLS + 9}`);
});
it('a stale agent:end for a foreign conversation cannot re-arm the send lock for a same-tick in-flight second turn', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmit('agent:end', { conversationId: 'c1' });
});
await act(async () => {
latest?.actions.sendMessage({ content: 'second turn' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
// While the second turn's send is in flight (sent but agent:start not yet
// received), a terminal event for an unrelated conversation arrives in the
// same tick as a follow-up send attempt — it must not re-arm the lock.
await act(async () => {
fake.serverEmit('agent:end', { conversationId: 'other' });
latest?.actions.sendMessage({ content: 'third turn attempt' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
expect(latest?.state.messages.some((m) => m.text === 'third turn attempt')).toBe(false);
});
it('a stale error for a foreign conversation cannot re-arm the send lock for a same-tick in-flight second turn', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmit('agent:end', { conversationId: 'c1' });
});
await act(async () => {
latest?.actions.sendMessage({ content: 'second turn' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
await act(async () => {
fake.serverEmit('error', { conversationId: 'other', error: 'unrelated failure' });
latest?.actions.sendMessage({ content: 'third turn attempt' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
expect(latest?.state.messages.some((m) => m.text === 'third turn attempt')).toBe(false);
});
it('does not throw and normalizes to empty lists when commands:manifest arrives with null commands/skills', async () => {
await expect(
act(async () => {
fake.serverEmitRaw('commands:manifest', {
manifest: { commands: null, skills: null, version: 1 },
});
}),
).resolves.not.toThrow();
expect(latest?.state.manifest?.commands).toEqual([]);
expect(latest?.state.manifest?.skills).toEqual([]);
});
it('does not throw and normalizes to empty lists when system:reload arrives with non-array commands/skills', async () => {
await expect(
act(async () => {
fake.serverEmitRaw('system:reload', {
commands: 'not-an-array',
skills: undefined,
providers: ['anthropic'],
message: 'Commands reloaded',
});
}),
).resolves.not.toThrow();
expect(latest?.state.manifest?.commands).toEqual([]);
expect(latest?.state.manifest?.skills).toEqual([]);
// The raw stored reload must be normalized the same way as the manifest —
// never left holding the malformed raw value.
expect(latest?.state.systemReload?.commands).toEqual([]);
expect(latest?.state.systemReload?.skills).toEqual([]);
});
it('falls back to a safe tool label when agent:tool:start carries a non-string toolName', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
await expect(
act(async () => {
fake.serverEmitRaw('agent:tool:start', {
conversationId: 'c1',
toolCallId: 't1',
toolName: { evil: 'object' },
});
}),
).resolves.not.toThrow();
expect(latest?.state.tools).toEqual([
{ toolCallId: 't1', toolName: 'Unknown tool', status: 'running' },
]);
});
it('ignores a session:info event whose entire payload is null, without throwing', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await expect(
act(async () => {
fake.serverEmitRaw('session:info', null);
}),
).resolves.not.toThrow();
expect(latest?.state.sessionInfo).toBeNull();
});
it('emits only one command:approve when approveCommand is called twice in the same tick, preserving the first frozen args', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
latest?.actions.approveCommand({ command: 'deploy', args: 'staging' });
});
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
expect(latest?.state.pendingApproval).toEqual({ command: 'deploy', args: 'prod' });
});
it('caps streamed agent:text at MAX_STREAM_CHARS behind a visible marker disclosing the honest dropped-character count across multiple appends', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
const firstChunk = 'a'.repeat(MAX_STREAM_CHARS);
const secondChunk = 'b'.repeat(500);
const thirdChunk = 'c'.repeat(300);
await act(async () => {
fake.serverEmit('agent:text', { conversationId: 'c1', text: firstChunk });
});
await act(async () => {
fake.serverEmit('agent:text', { conversationId: 'c1', text: secondChunk });
});
await act(async () => {
fake.serverEmit('agent:text', { conversationId: 'c1', text: thirdChunk });
});
const text = latest?.state.text ?? '';
expect(text.length).toBeLessThanOrEqual(MAX_STREAM_CHARS);
const match = text.match(/^…\[truncated (\d+) characters\]…/);
expect(match).not.toBeNull();
const markerLength = match?.[0].length ?? 0;
const droppedCount = Number(match?.[1]);
const tail = text.slice(markerLength);
const totalStreamed = firstChunk.length + secondChunk.length + thirdChunk.length;
// The marker's N must be the actual count of original characters no
// longer visible — not an estimate — across all three appends.
expect(droppedCount).toBe(totalStreamed - tail.length);
expect(tail.endsWith(thirdChunk)).toBe(true);
});
it('caps streamed agent:thinking at MAX_STREAM_CHARS behind the same honest truncation marker as agent:text', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
const firstChunk = 'x'.repeat(MAX_STREAM_CHARS);
const secondChunk = 'y'.repeat(500);
const thirdChunk = 'z'.repeat(300);
await act(async () => {
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: firstChunk });
});
await act(async () => {
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: secondChunk });
});
await act(async () => {
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: thirdChunk });
});
const thinking = latest?.state.thinking ?? '';
expect(thinking.length).toBeLessThanOrEqual(MAX_STREAM_CHARS);
const match = thinking.match(/^…\[truncated (\d+) characters\]…/);
expect(match).not.toBeNull();
const markerLength = match?.[0].length ?? 0;
const droppedCount = Number(match?.[1]);
const tail = thinking.slice(markerLength);
const totalStreamed = firstChunk.length + secondChunk.length + thirdChunk.length;
expect(droppedCount).toBe(totalStreamed - tail.length);
expect(tail.endsWith(thirdChunk)).toBe(true);
});
it('caps commandResults at MAX_COMMAND_RESULTS when flooded with command:result events', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
for (let i = 0; i < MAX_COMMAND_RESULTS + 5; i += 1) {
fake.serverEmit('command:result', {
conversationId: 'c1',
command: `cmd${i}`,
success: true,
});
}
});
expect(latest?.state.commandResults).toHaveLength(MAX_COMMAND_RESULTS);
expect(latest?.state.commandResults.at(-1)?.command).toBe(`cmd${MAX_COMMAND_RESULTS + 4}`);
});
it('caps commands:manifest commands/skills at MAX_MANIFEST_ITEMS', async () => {
const commands = Array.from({ length: MAX_MANIFEST_ITEMS + 5 }, (_, i) => ({
name: `cmd${i}`,
aliases: [],
description: '',
scope: 'core' as const,
execution: 'socket' as const,
available: true,
}));
await act(async () => {
fake.serverEmit('commands:manifest', { manifest: { commands, skills: [], version: 1 } });
});
expect(latest?.state.manifest?.commands).toHaveLength(MAX_MANIFEST_ITEMS);
});
it('caps a system:reload manifest replacement at MAX_MANIFEST_ITEMS for commands, skills, and providers, and drops hostile extra fields instead of spreading the raw payload into state', async () => {
const commands = Array.from({ length: MAX_MANIFEST_ITEMS + 5 }, (_, i) => ({
name: `cmd${i}`,
aliases: [],
description: '',
scope: 'core' as const,
execution: 'socket' as const,
available: true,
}));
const skills = Array.from({ length: MAX_MANIFEST_ITEMS + 5 }, (_, i) => ({
name: `skill${i}`,
description: '',
available: true,
}));
const providers = Array.from({ length: 550 }, (_, i) => `provider-${i}`);
await expect(
act(async () => {
fake.serverEmitRaw('system:reload', {
commands,
skills,
providers,
message: 'reloaded',
// Hostile field not part of the SystemReloadPayload contract —
// must never survive into state.systemReload.
maliciousExtra: 'should-not-survive',
});
}),
).resolves.not.toThrow();
expect(latest?.state.manifest?.commands).toHaveLength(MAX_MANIFEST_ITEMS);
expect(latest?.state.manifest?.skills).toHaveLength(MAX_MANIFEST_ITEMS);
// The raw stored reload (state.systemReload) must be bounded the same
// way as the manifest derived from it — a hostile/oversized broadcast
// must never leave the uncapped raw payload sitting in state.
expect(latest?.state.systemReload?.commands).toHaveLength(MAX_MANIFEST_ITEMS);
expect(latest?.state.systemReload?.skills).toHaveLength(MAX_MANIFEST_ITEMS);
expect(latest?.state.systemReload?.providers).toHaveLength(500);
expect(latest?.state.systemReload).not.toHaveProperty('maliciousExtra');
});
it('caps availableThinkingLevels before storing a hostile session:info payload', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const hostileLevels = Array.from({ length: MAX_MANIFEST_ITEMS + 50 }, (_, i) => `level-${i}`);
await act(async () => {
fake.serverEmit('session:info', {
conversationId: 'c1',
provider: 'anthropic',
modelId: 'claude',
thinkingLevel: 'level-0',
availableThinkingLevels: hostileLevels,
});
});
expect(latest?.state.sessionInfo?.availableThinkingLevels).toHaveLength(MAX_MANIFEST_ITEMS);
});
it('ignores a malformed (object) conversationId on the establishing message:ack, leaving the turn recoverable for a later valid ack', async () => {
await act(async () => {
latest?.actions.sendMessage({ content: 'hi' });
});
expect(latest?.state.pendingSend).toBe(true);
await expect(
act(async () => {
fake.serverEmitRaw('message:ack', { conversationId: { bad: 'object' }, messageId: 'm0' });
}),
).resolves.not.toThrow();
expect(latest?.state.conversationId).toBeNull();
expect(latest?.state.pendingSend).toBe(true);
expect(latest?.state.ack).toBeNull();
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 an empty-string and a null conversationId on the establishing message:ack', async () => {
await expect(
act(async () => {
fake.serverEmitRaw('message:ack', { conversationId: '', messageId: 'm1' });
}),
).resolves.not.toThrow();
expect(latest?.state.conversationId).toBeNull();
await expect(
act(async () => {
fake.serverEmitRaw('message:ack', { conversationId: null, messageId: 'm1' });
}),
).resolves.not.toThrow();
expect(latest?.state.conversationId).toBeNull();
});
it('sanitizes a malformed (non-string) ack messageId to a visible "unknown" fallback instead of storing the raw value', async () => {
await expect(
act(async () => {
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
}),
).resolves.not.toThrow();
expect(latest?.state.ack).toEqual({ conversationId: 'c1', messageId: 'unknown' });
});
it('treats a malformed-conversationId error as a terminal startup failure for a brand-new pending send, releasing the send lock for a retry', async () => {
await act(async () => {
latest?.actions.sendMessage({ content: 'hi' });
});
expect(latest?.state.pendingSend).toBe(true);
expect(latest?.state.sending).toBe(true);
await expect(
act(async () => {
fake.serverEmitRaw('error', { conversationId: { bad: 'object' }, error: 'boom' });
}),
).resolves.not.toThrow();
expect(latest?.state.conversationId).toBeNull();
expect(latest?.state.pendingSend).toBe(false);
expect(latest?.state.sending).toBe(false);
expect(latest?.state.streaming).toBe(false);
expect(latest?.state.approvalRequestPending).toBe(false);
expect(latest?.state.error).toBe('Unable to start this conversation. Please try again.');
await act(async () => {
latest?.actions.sendMessage({ content: 'retry' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(2);
expect(latest?.state.messages.some((m) => m.text === 'retry')).toBe(true);
});
it('treats a malformed-conversationId agent:end as a terminal startup failure for a brand-new pending send, releasing the send lock for a retry', async () => {
await act(async () => {
latest?.actions.sendMessage({ content: 'hi' });
});
expect(latest?.state.pendingSend).toBe(true);
await expect(
act(async () => {
fake.serverEmitRaw('agent:end', { conversationId: '' });
}),
).resolves.not.toThrow();
expect(latest?.state.conversationId).toBeNull();
expect(latest?.state.pendingSend).toBe(false);
expect(latest?.state.sending).toBe(false);
expect(latest?.state.streaming).toBe(false);
expect(latest?.state.approvalRequestPending).toBe(false);
expect(latest?.state.error).toBe('Unable to start this conversation. Please try again.');
await act(async () => {
latest?.actions.sendMessage({ content: 'retry again' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(2);
expect(latest?.state.messages.some((m) => m.text === 'retry again')).toBe(true);
});
it('a foreign valid-conversationId error after a conversation is already active remains ignored and cannot unlock anything', 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: 'other', error: 'unrelated' });
});
expect(latest?.state.conversationId).toBe('c1');
expect(latest?.state.streaming).toBe(true);
expect(latest?.state.error).toBeNull();
});
it('ignores a malformed (null) conversationId on an establishing agent:start via resolveScopedConversation, leaving the turn recoverable for a later valid one', async () => {
await act(async () => {
latest?.actions.sendMessage({ content: 'hi' });
});
await expect(
act(async () => {
fake.serverEmitRaw('agent:start', { conversationId: null });
}),
).resolves.not.toThrow();
expect(latest?.state.conversationId).toBeNull();
expect(latest?.state.pendingSend).toBe(true);
expect(latest?.state.streaming).toBe(false);
await act(async () => {
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
expect(latest?.state.conversationId).toBe('c1');
expect(latest?.state.streaming).toBe(true);
});
it('normalizes a command:approval with a truthy but non-literal-true success into a denial, releasing the pending lock without enabling execution', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
});
await expect(
act(async () => {
fake.serverEmitRaw('command:approval', {
conversationId: 'c1',
command: 'deploy',
success: { truthy: 'object' },
approvalId: 'ap1',
});
}),
).resolves.not.toThrow();
expect(latest?.state.approval?.success).toBe(false);
expect(latest?.state.approvalRequestPending).toBe(false);
await act(async () => {
latest?.actions.runApprovedCommand();
});
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(0);
});
it('normalizes a command:approval with success: true but a malformed (object) approvalId into a denial that cannot be run', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
});
await expect(
act(async () => {
fake.serverEmitRaw('command:approval', {
conversationId: 'c1',
command: 'deploy',
success: true,
approvalId: { bad: 'object' },
});
}),
).resolves.not.toThrow();
expect(latest?.state.approval?.success).toBe(false);
expect(latest?.state.approval?.approvalId).toBeUndefined();
expect(latest?.state.approvalRequestPending).toBe(false);
await act(async () => {
latest?.actions.runApprovedCommand();
});
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(0);
});
it('normalizes command:result.success to a literal boolean, never displaying a truthy non-boolean value as success', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await expect(
act(async () => {
fake.serverEmitRaw('command:result', {
conversationId: 'c1',
command: 'model',
success: { truthy: 'object' },
});
}),
).resolves.not.toThrow();
expect(latest?.state.commandResults.at(-1)?.success).toBe(false);
});
it('caps messages at MAX_MESSAGES when flooded with agent:start/text/end cycles on an established conversation', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
for (let i = 0; i < MAX_MESSAGES + 5; i += 1) {
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmit('agent:text', { conversationId: 'c1', text: `reply ${i}` });
fake.serverEmit('agent:end', { conversationId: 'c1' });
}
});
expect(latest?.state.messages).toHaveLength(MAX_MESSAGES);
expect(latest?.state.messages.at(-1)?.text).toBe(`reply ${MAX_MESSAGES + 4}`);
// Every retained message.id must stay unique across the cap boundary — a
// length-derived id would collide once the array plateaus at MAX_MESSAGES.
const ids = latest?.state.messages.map((m) => m.id) ?? [];
expect(new Set(ids).size).toBe(ids.length);
});
it('fails closed after the consumed approval cache saturates and rejects replay of the ID the old eviction policy forgot', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
// Fill the bounded dedup set to capacity with genuinely distinct,
// legitimately approved and executed IDs. Each step of approve/respond/
// run gets its own `act()` so a render (and the ref-syncing effects)
// lands between them — combining approve+respond in one act would let
// `approvalRequestPending` flip true-then-false within a single commit,
// skipping the render the lock-sync effect depends on.
for (let i = 0; i < MAX_EXECUTED_APPROVAL_IDS; i += 1) {
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy' });
});
await act(async () => {
fake.serverEmit('command:approval', {
conversationId: 'c1',
command: 'deploy',
success: true,
approvalId: `ap-${i}`,
});
});
await act(async () => {
latest?.actions.runApprovedCommand();
});
}
// One more distinct approval once the set is already full. The old
// eviction policy would delete the oldest entry (ap-0) to make room and
// let this execute; the fixed behavior must deny it instead.
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy' });
});
await act(async () => {
fake.serverEmit('command:approval', {
conversationId: 'c1',
command: 'deploy',
success: true,
approvalId: 'ap-overflow',
});
});
await act(async () => {
latest?.actions.runApprovedCommand();
});
// The rejected 201st approval is not silently dropped — it must be
// consumed but also surface a stable, visible notice so the user knows
// why the command did not run. ChatPage renders state.error as
// role="alert".
expect(latest?.state.error).toBe(
'Approval limit reached for this session. This command was not run.',
);
expect(latest?.state.approval).toBeNull();
expect(latest?.state.pendingApproval).toBeNull();
// A fresh local approval request, replaying the very first approvalId —
// this is the replay the old eviction policy would have let through a
// second time because it had forgotten ap-0 ever ran.
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy' });
});
await act(async () => {
fake.serverEmit('command:approval', {
conversationId: 'c1',
command: 'deploy',
success: true,
approvalId: 'ap-0',
});
});
await act(async () => {
latest?.actions.runApprovedCommand();
});
const executes = fake.emitted.filter(
(e): e is EmittedEvent<'command:execute'> => e.event === 'command:execute',
);
// Only the original MAX_EXECUTED_APPROVAL_IDS executions ever happened —
// neither the overflow ID nor the replay of ap-0 produced a new one.
expect(executes).toHaveLength(MAX_EXECUTED_APPROVAL_IDS);
expect(executes.filter((e) => e.payload.approvalId === 'ap-0')).toHaveLength(1);
expect(executes.some((e) => e.payload.approvalId === 'ap-overflow')).toBe(false);
});
it('does not unlock turn B when a stale same-conversation agent:end from turn A arrives', async () => {
// Turn A completes normally on c1.
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmit('agent:end', { conversationId: 'c1' });
});
expect(latest?.state.sending).toBe(false);
// Turn B is sent (same conversation — the wire gives it no distinct
// turn id, only the same conversationId as turn A).
await act(async () => {
latest?.actions.sendMessage({ content: 'turn B' });
});
expect(latest?.state.sending).toBe(true);
// Turn A's agent:end arrives late (network reordering) for the SAME
// conversation, before turn B's own ack/start ever arrived. A
// conversationId-only check cannot tell this apart from turn B's own
// terminal event, so this must not unlock — turn B has not yet been
// armed by its own accepted ack/start.
await act(async () => {
fake.serverEmit('agent:end', { conversationId: 'c1' });
});
expect(latest?.state.sending).toBe(true);
// A recognized-stale terminal must be a true no-op — it must not touch
// streaming/error, which legitimately belong to the still in-flight
// turn B.
expect(latest?.state.streaming).toBe(false);
expect(latest?.state.error).toBeNull();
// An attempted turn C must still be rejected — the lock is still held.
await act(async () => {
latest?.actions.sendMessage({ content: 'turn C attempt' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
expect(latest?.state.messages.some((m) => m.text === 'turn C attempt')).toBe(false);
// Turn B's own, current ack/start arms the lock, and its own terminal
// event can then legitimately release it, allowing a later send.
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm2' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
await act(async () => {
fake.serverEmit('agent:end', { conversationId: 'c1' });
});
expect(latest?.state.sending).toBe(false);
await act(async () => {
latest?.actions.sendMessage({ content: 'turn D' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(2);
});
it('settles turn B and releases its lock when a pre-ack error arrives on an already-established conversation, allowing a later send', async () => {
// Turn A completes normally on c1.
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmit('agent:end', { conversationId: 'c1' });
});
expect(latest?.state.sending).toBe(false);
await act(async () => {
latest?.actions.sendMessage({ content: 'turn B' });
});
expect(latest?.state.sending).toBe(true);
// A same-conversation error arrives before turn B's own ack/start. Once
// turn A has already fully settled via its own terminal event, ordered
// Socket.IO delivery means this cannot be a leftover of A — the Gateway
// has nothing left in flight to emit for a turn it already finished. It
// can only be a genuine error for the newly sent turn B (e.g. a
// session-creation failure emitted before ack), so it must settle B.
await act(async () => {
fake.serverEmit('error', { conversationId: 'c1', error: 'turn B session failure' });
});
expect(latest?.state.sending).toBe(false);
expect(latest?.state.streaming).toBe(false);
expect(latest?.state.error).toBe('turn B session failure');
await act(async () => {
latest?.actions.sendMessage({ content: 'turn C' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(2);
expect(latest?.state.messages.some((m) => m.text === 'turn C')).toBe(true);
});
it('invalidates an outstanding approval request when a pre-ack error settles turn B on an already-established conversation', async () => {
// Turn A completes normally on c1.
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmit('agent:end', { conversationId: 'c1' });
});
expect(latest?.state.sending).toBe(false);
// Turn B is sent but has not yet received its own ack/start.
await act(async () => {
latest?.actions.sendMessage({ content: 'turn B' });
});
expect(latest?.state.sending).toBe(true);
// An approval request is outstanding on c1 — approveCommand has no
// dependency on `sending`/`streaming`, so this is legitimate even while
// turn B has not yet started.
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
});
expect(latest?.state.approvalRequestPending).toBe(true);
// A genuine pre-ack error for turn B is terminal — same as an
// active-turn error, it invalidates any approval request still awaiting
// a response, since the Gateway that just errored is unlikely to still
// answer it.
await act(async () => {
fake.serverEmit('error', { conversationId: 'c1', error: 'turn B session failure' });
});
expect(latest?.state.sending).toBe(false);
expect(latest?.state.approvalRequestPending).toBe(false);
expect(latest?.state.error).toBe('turn B session failure');
// A fresh approval request can be issued again after the invalidation.
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy', args: 'staging' });
});
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(2);
});
it("P3-5c: locks the achievable pre-start boundary — turn A settled, turn B sent and acked but before B's own start, a stale/duplicate agent:end from A does not release B (a third send stays blocked), and only B's own start/end sequence legitimately releases it", async () => {
// This is the provable boundary: in-order Socket.IO delivery guarantees
// a same-conversation agent:end arriving before this turn's own
// agent:start can only be a stale straggler. Once a turn is 'active',
// AgentEndPayload/ErrorPayload carry no turn identity to further
// distinguish a genuine end from a duplicate — that residual is not,
// and cannot be, asserted here; full correlation needs a wire turnId
// (deferred to P5). See the reducer comments in use-chat-connection.ts.
// Turn A completes normally on c1.
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
fake.serverEmit('agent:end', { conversationId: 'c1' });
});
expect(latest?.state.sending).toBe(false);
// Turn B is sent and its own ack arrives. Under the old
// ack-arms-the-lock design this alone made the lock releasable by any
// same-conversation terminal — the precise bug: it could not yet
// distinguish B's own eventual agent:end from a late duplicate delivery
// of A's already-consumed one.
await act(async () => {
latest?.actions.sendMessage({ content: 'turn B' });
});
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm2' });
});
expect(latest?.state.sending).toBe(true);
// A duplicate/straggler agent:end for turn A (already fully settled
// above) is redelivered for the same conversation before B's own
// agent:start ever arrived. Only B's own accepted agent:start may move
// it into the active phase that a real agent:end may settle — this
// duplicate must be a true no-op.
await act(async () => {
fake.serverEmit('agent:end', { conversationId: 'c1' });
});
expect(latest?.state.sending).toBe(true);
expect(latest?.state.streaming).toBe(false);
// Turn C must still be blocked — the lock is still genuinely held by B.
await act(async () => {
latest?.actions.sendMessage({ content: 'turn C attempt' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
expect(latest?.state.messages.some((m) => m.text === 'turn C attempt')).toBe(false);
// B's own start, then its own end, legitimately unlocks it.
await act(async () => {
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
expect(latest?.state.streaming).toBe(true);
await act(async () => {
fake.serverEmit('agent:end', { conversationId: 'c1' });
});
expect(latest?.state.sending).toBe(false);
await act(async () => {
latest?.actions.sendMessage({ content: 'turn D' });
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(2);
});
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();
});
});