fix(web): harden typed SPA chat lifecycle
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
b2e005f2b4
commit
caebf9ef70
@@ -2,6 +2,13 @@ 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';
|
||||
import {
|
||||
MAX_COMMAND_RESULTS,
|
||||
MAX_MANIFEST_ITEMS,
|
||||
MAX_MESSAGES,
|
||||
MAX_STREAM_CHARS,
|
||||
MAX_TOOLS,
|
||||
} from './limits';
|
||||
|
||||
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
||||
getSocketMock: vi.fn(),
|
||||
@@ -590,6 +597,654 @@ describe('useChatConnection', () => {
|
||||
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([]);
|
||||
});
|
||||
|
||||
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, keeping only the most recent characters', 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: 'a'.repeat(MAX_STREAM_CHARS) });
|
||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'b'.repeat(10) });
|
||||
});
|
||||
|
||||
expect(latest?.state.text).toHaveLength(MAX_STREAM_CHARS);
|
||||
expect(latest?.state.text.endsWith('b'.repeat(10))).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', 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('system:reload', {
|
||||
commands,
|
||||
skills: [],
|
||||
providers: ['anthropic'],
|
||||
message: 'reloaded',
|
||||
});
|
||||
});
|
||||
|
||||
expect(latest?.state.manifest?.commands).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('removes every listener and tears down the socket on cleanup, using no network', async () => {
|
||||
const registeredEvents = [...fake.listeners.keys()];
|
||||
expect(registeredEvents.length).toBeGreaterThan(0);
|
||||
|
||||
Reference in New Issue
Block a user