fix(web): close P3 chat re-review findings

This commit is contained in:
shaggy (mosaic-dev box)
2026-08-10 01:58:27 -05:00
parent caebf9ef70
commit 48bb19310d
10 changed files with 774 additions and 65 deletions
+33 -8
View File
@@ -10,25 +10,45 @@ vi.mock('socket.io-client', () => ({
import { destroySocket, getSocket } from './socket'; import { destroySocket, getSocket } from './socket';
function createMockSocket(): { interface MockChatSocket {
on: ReturnType<typeof vi.fn>; on: ReturnType<typeof vi.fn>;
offAny: ReturnType<typeof vi.fn>; offAny: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn>;
} { /** Test-only helper: fires every handler registered for `event` via
const mockSocket = { * `.on`, mirroring how a real socket.io-client instance invokes its own
on: vi.fn(() => mockSocket), * listeners (e.g. calling the registered `disconnect` handler(s) on a
* real transient disconnect). */
trigger(event: string): void;
}
function createMockSocket(): MockChatSocket {
const handlers = new Map<string, Set<() => void>>();
const mockSocket: MockChatSocket = {
on: vi.fn((event: string, handler: () => void) => {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event)?.add(handler);
return mockSocket;
}),
offAny: vi.fn(() => mockSocket), offAny: vi.fn(() => mockSocket),
disconnect: vi.fn(() => mockSocket), disconnect: vi.fn(() => mockSocket),
trigger(event: string): void {
for (const handler of handlers.get(event) ?? []) handler();
},
}; };
return mockSocket; return mockSocket;
} }
let currentMock!: MockChatSocket;
describe('chat socket', () => { describe('chat socket', () => {
beforeEach(() => { beforeEach(() => {
ioMock.mockReset(); ioMock.mockReset();
// A fresh object per io() call so identity assertions (same singleton vs. // A fresh object per io() call so identity assertions (same singleton vs.
// a genuinely new instance) are meaningful. // a genuinely new instance) are meaningful.
ioMock.mockImplementation(() => createMockSocket()); ioMock.mockImplementation(() => {
currentMock = createMockSocket();
return currentMock;
});
}); });
afterEach(() => { afterEach(() => {
@@ -52,9 +72,14 @@ describe('chat socket', () => {
const first = getSocket(); const first = getSocket();
// socket.ts must not react to a real socket's `disconnect` event by // socket.ts must not react to a real socket's `disconnect` event by
// nulling the singleton — it registers no such handler at all now, so // nulling the singleton — it registers no such handler at all now.
// simply calling getSocket() again after a "disconnect" must still // Actually fire every handler registered via `.on('disconnect', ...)`
// return the same instance. // (mirroring a real socket.io-client reconnect) instead of merely
// calling getSocket() again: this is what makes the test fail if
// production reintroduces `socket.on('disconnect', () => { socket =
// null; })`, since that handler would run here and null the singleton
// before the next getSocket() call.
currentMock.trigger('disconnect');
const second = getSocket(); const second = getSocket();
expect(second).toBe(first); expect(second).toBe(first);
@@ -174,6 +174,66 @@ describe('CommandsPanel', () => {
).toBe(false); ).toBe(false);
}); });
it('shows the guarded server-provided denial reason for a denied approval', async () => {
await render(
<CommandsPanel
manifest={null}
results={[]}
approval={{
conversationId: 'c1',
command: 'deploy',
success: false,
message: 'Not authorized',
}}
pendingApproval={{ command: 'deploy', args: 'prod' }}
hasConversation
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent).toContain('Not authorized');
});
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
await render(
<CommandsPanel
manifest={null}
results={[]}
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
pendingApproval={{ command: 'deploy', args: 'prod' }}
hasConversation
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent).toContain('Denied.');
});
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
await render(
<CommandsPanel
manifest={null}
results={[
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
{ conversationId: 'c1', command: 'deploy', success: false },
]}
approval={null}
pendingApproval={null}
hasConversation={false}
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent).toContain('Unknown model');
expect(container?.textContent).toContain('Command failed.');
});
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => { it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
const manifest = { const manifest = {
commands: 'not-an-array', commands: 'not-an-array',
+17 -9
View File
@@ -1,14 +1,16 @@
import { useState, type ReactElement } from 'react'; import { useState, type ReactElement } from 'react';
import type { PendingApproval } from './use-chat-connection'; import type { PendingApproval } from './use-chat-connection';
import { asString } from './runtime-guards'; import { asNonEmptyString, asString } from './runtime-guards';
import type { import type {
CommandManifest, CommandManifest,
SlashCommandApprovalResultPayload, SlashCommandApprovalResultPayload,
SlashCommandResultPayload, SlashCommandResultPayload,
} from '@/lib/chat-contract'; } from '@/lib/chat-contract';
/** Stable client copy shown for a failed command — never the raw server /** Stable fallback copy shown for a failed command only when the server's
* detail, which could leak internal error text to the user. */ * own guarded, non-empty `message` (e.g. "Unknown model") is absent or
* malformed — the structured contract reason itself is otherwise shown
* directly, never a raw thrown exception, stack trace, or object value. */
const COMMAND_FAILURE_COPY = 'Command failed.'; const COMMAND_FAILURE_COPY = 'Command failed.';
interface CommandsPanelProps { interface CommandsPanelProps {
@@ -104,11 +106,17 @@ export function CommandsPanel({
{approval ? ( {approval ? (
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2"> <div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
{/* Stable client copy only — never the server-controlled {/* A successful approval shows stable client copy only — never
approval.message or echoed approval.command as the primary the server-controlled approval.message or echoed
confirmation. The frozen local pendingApproval below (not this approval.command as the primary confirmation. The frozen local
line) is the sole authoritative statement of what will run. */} pendingApproval below (not this line) is the sole authoritative
<span>{approval.success ? 'Approved.' : 'Denied.'}</span> statement of what will run. A denial, by contrast, is not an
execution authority and safely surfaces the guarded structured
reason the server gave (e.g. "Not authorized"), falling back to
a stable copy only when absent/malformed. */}
<span>
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
</span>
{canRunApproved && pendingApproval ? ( {canRunApproved && pendingApproval ? (
<> <>
{/* Authoritative frozen local command+args — what the click below {/* Authoritative frozen local command+args — what the click below
@@ -135,7 +143,7 @@ export function CommandsPanel({
? typeof result.message === 'string' && result.message ? typeof result.message === 'string' && result.message
? `${result.message}` ? `${result.message}`
: '' : ''
: `${COMMAND_FAILURE_COPY}`} : `${asNonEmptyString(result.message, COMMAND_FAILURE_COPY)}`}
</li> </li>
))} ))}
</ul> </ul>
+8
View File
@@ -10,6 +10,14 @@ export function asString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback; return typeof value === 'string' ? value : fallback;
} }
/** Like `asString`, but an empty string also falls back — used for guarded
* contract-provided reason strings (e.g. a denial or failure message) where
* an empty string is not a meaningful value to display in place of the
* stable fallback copy. */
export function asNonEmptyString(value: unknown, fallback: string): string {
return typeof value === 'string' && value.length > 0 ? value : fallback;
}
export function asFiniteNumber(value: unknown, fallback = 0): number { export function asFiniteNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback; return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
} }
+8 -1
View File
@@ -1,5 +1,6 @@
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import type { SessionInfoPayload } from '@/lib/chat-contract'; import type { SessionInfoPayload } from '@/lib/chat-contract';
import { MAX_MANIFEST_ITEMS } from './limits';
import { asString, asStringArray } from './runtime-guards'; import { asString, asStringArray } from './runtime-guards';
interface SessionPanelProps { interface SessionPanelProps {
@@ -15,7 +16,13 @@ export function SessionPanel({
}: SessionPanelProps): ReactElement | null { }: SessionPanelProps): ReactElement | null {
if (!sessionInfo) return null; if (!sessionInfo) return null;
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels); // The reducer already caps this before storing it, but the render site
// defends independently — a hostile payload must never be able to force
// this <select> to lay out an unbounded number of options.
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels).slice(
0,
MAX_MANIFEST_ITEMS,
);
const hasThinkingLevels = availableThinkingLevels.length > 0; const hasThinkingLevels = availableThinkingLevels.length > 0;
return ( return (
@@ -1,9 +1,10 @@
import { act } from 'react'; import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client'; import { createRoot, type Root } from 'react-dom/client';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFakeChatSocket } from './test-support/fake-chat-socket'; import { createFakeChatSocket, type EmittedEvent } from './test-support/fake-chat-socket';
import { import {
MAX_COMMAND_RESULTS, MAX_COMMAND_RESULTS,
MAX_EXECUTED_APPROVAL_IDS,
MAX_MANIFEST_ITEMS, MAX_MANIFEST_ITEMS,
MAX_MESSAGES, MAX_MESSAGES,
MAX_STREAM_CHARS, MAX_STREAM_CHARS,
@@ -430,6 +431,14 @@ describe('useChatConnection', () => {
expect(latest?.state.conversationId).toBe('c1'); expect(latest?.state.conversationId).toBe('c1');
expect(latest?.state.error).toBe('Failed to start agent session. Please try again.'); expect(latest?.state.error).toBe('Failed to start agent session. Please try again.');
expect(latest?.state.streaming).toBe(false); 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 () => { it('stops streaming when a typed error arrives mid-turn', async () => {
@@ -881,6 +890,10 @@ describe('useChatConnection', () => {
expect(latest?.state.manifest?.commands).toEqual([]); expect(latest?.state.manifest?.commands).toEqual([]);
expect(latest?.state.manifest?.skills).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 () => { it('falls back to a safe tool label when agent:tool:start carries a non-string toolName', async () => {
@@ -932,19 +945,74 @@ describe('useChatConnection', () => {
expect(latest?.state.pendingApproval).toEqual({ command: 'deploy', args: 'prod' }); 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 () => { 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 () => { await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('agent:start', { conversationId: 'c1' }); 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 () => { await act(async () => {
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'a'.repeat(MAX_STREAM_CHARS) }); fake.serverEmit('agent:text', { conversationId: 'c1', text: firstChunk });
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'b'.repeat(10) }); });
await act(async () => {
fake.serverEmit('agent:text', { conversationId: 'c1', text: secondChunk });
});
await act(async () => {
fake.serverEmit('agent:text', { conversationId: 'c1', text: thirdChunk });
}); });
expect(latest?.state.text).toHaveLength(MAX_STREAM_CHARS); const text = latest?.state.text ?? '';
expect(latest?.state.text.endsWith('b'.repeat(10))).toBe(true); 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 () => { it('caps commandResults at MAX_COMMAND_RESULTS when flooded with command:result events', async () => {
@@ -983,7 +1051,7 @@ describe('useChatConnection', () => {
expect(latest?.state.manifest?.commands).toHaveLength(MAX_MANIFEST_ITEMS); expect(latest?.state.manifest?.commands).toHaveLength(MAX_MANIFEST_ITEMS);
}); });
it('caps a system:reload manifest replacement at MAX_MANIFEST_ITEMS', async () => { it('caps a system:reload manifest replacement at MAX_MANIFEST_ITEMS for both the raw stored reload and the manifest', async () => {
const commands = Array.from({ length: MAX_MANIFEST_ITEMS + 5 }, (_, i) => ({ const commands = Array.from({ length: MAX_MANIFEST_ITEMS + 5 }, (_, i) => ({
name: `cmd${i}`, name: `cmd${i}`,
aliases: [], aliases: [],
@@ -992,17 +1060,48 @@ describe('useChatConnection', () => {
execution: 'socket' as const, execution: 'socket' as const,
available: true, available: true,
})); }));
const skills = Array.from({ length: MAX_MANIFEST_ITEMS + 5 }, (_, i) => ({
name: `skill${i}`,
description: '',
available: true,
}));
await act(async () => { await act(async () => {
fake.serverEmit('system:reload', { fake.serverEmit('system:reload', {
commands, commands,
skills: [], skills,
providers: ['anthropic'], providers: ['anthropic'],
message: 'reloaded', message: 'reloaded',
}); });
}); });
expect(latest?.state.manifest?.commands).toHaveLength(MAX_MANIFEST_ITEMS); 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);
});
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 () => { it('ignores a malformed (object) conversationId on the establishing message:ack, leaving the turn recoverable for a later valid ack', async () => {
@@ -1245,6 +1344,234 @@ describe('useChatConnection', () => {
expect(new Set(ids).size).toBe(ids.length); 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();
});
// 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('does not unlock turn B when a stale same-conversation error from turn A arrives', async () => {
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 stale error for turn A, same conversationId, arrives before turn
// B's own ack/start — must not unlock turn B.
await act(async () => {
fake.serverEmit('error', { conversationId: 'c1', error: 'stale turn A failure' });
});
expect(latest?.state.sending).toBe(true);
// A recognized-stale terminal must be a true no-op — its message must
// never be displayed/stored, and it must not touch streaming, which
// legitimately belongs to the still in-flight turn B.
expect(latest?.state.error).toBeNull();
expect(latest?.state.streaming).toBe(false);
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; its own terminal event
// (here, its own error) can then legitimately release it.
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm2' });
fake.serverEmit('agent:start', { conversationId: 'c1' });
});
await act(async () => {
fake.serverEmit('error', { conversationId: 'c1', error: 'turn B failed' });
});
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('a recognized-stale same-conversation error during an unarmed turn B does not clear approvalRequestPending, does not overwrite the frozen pendingApproval, and does not surface its message', 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 been armed by 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 is unarmed.
await act(async () => {
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
});
expect(latest?.state.approvalRequestPending).toBe(true);
expect(latest?.state.pendingApproval).toEqual({ command: 'deploy', args: 'prod' });
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
// A stale error from turn A, same conversationId, arrives before turn
// B's own ack/start. This must be a true no-op: it must not release
// `sending`, must not display/store its message, must not touch
// `streaming`, and — critically — must not clear
// `approvalRequestPending`/`pendingApproval`, which would re-arm the
// approve UI for a request that is still outstanding.
await act(async () => {
fake.serverEmit('error', { conversationId: 'c1', error: 'stale turn A failure' });
});
expect(latest?.state.sending).toBe(true);
expect(latest?.state.approvalRequestPending).toBe(true);
expect(latest?.state.pendingApproval).toEqual({ command: 'deploy', args: 'prod' });
expect(latest?.state.error).toBeNull();
expect(latest?.state.streaming).toBe(false);
// A second approval attempt while the first is still outstanding must
// still be rejected — exactly one command:approve total, and the
// original frozen command+args must be unchanged.
await act(async () => {
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('removes every listener and tears down the socket on cleanup, using no network', async () => { it('removes every listener and tears down the socket on cleanup, using no network', async () => {
const registeredEvents = [...fake.listeners.keys()]; const registeredEvents = [...fake.listeners.keys()];
expect(registeredEvents.length).toBeGreaterThan(0); expect(registeredEvents.length).toBeGreaterThan(0);
+235 -30
View File
@@ -8,7 +8,13 @@ import {
MAX_STREAM_CHARS, MAX_STREAM_CHARS,
MAX_TOOLS, MAX_TOOLS,
} from './limits'; } from './limits';
import { asConversationId, asFiniteNumber, asString, isRecord } from './runtime-guards'; import {
asConversationId,
asFiniteNumber,
asString,
asStringArray,
isRecord,
} from './runtime-guards';
import type { import type {
AgentEndPayload, AgentEndPayload,
AgentStartPayload, AgentStartPayload,
@@ -35,11 +41,64 @@ export interface ToolCallState {
status: 'running' | 'success' | 'error' | 'anomaly'; status: 'running' | 'success' | 'error' | 'anomaly';
} }
/** Appends `addition` to `existing`, keeping at most `max` characters by /** Formats the visible marker prepended to a capped stream buffer once any
* dropping the oldest (leading) characters once the cap is exceeded. */ * original characters have been dropped from it. Reconstructing the prior
function capAppendString(existing: string, addition: string, max: number): string { * tail (see `capAppendStream` below) always slices this exact computed
const next = existing + addition; * length off the front of the previous displayed value — it never scans
return next.length > max ? next.slice(next.length - max) : next; * buffer content for marker-shaped text, so real streamed content that
* happens to look like a marker can never be mistaken for one. */
function formatTruncationMarker(dropped: number): string {
return `…[truncated ${dropped} characters]…`;
}
interface CappedStream {
/** The full value to store/display — a plain tail when nothing has been
* dropped yet, otherwise the marker followed by the retained tail. Always
* bounded to at most `max` characters in total. */
displayed: string;
/** Total original stream characters dropped so far, cumulative across
* every append — never reset while the buffer is still accumulating. */
dropped: number;
}
/** Appends `addition` to a stream buffer capped at `max` displayed
* characters (marker included), honestly disclosing how many original
* characters have been dropped so far rather than silently retaining only
* the tail. `priorDisplayed`/`priorDropped` come from state; the marker (if
* any) already present in `priorDisplayed` is stripped by the exact length
* `formatTruncationMarker(priorDropped)` computes, not by pattern-matching. */
function capAppendStream(
priorDisplayed: string,
priorDropped: number,
addition: string,
max: number,
): CappedStream {
const priorTail =
priorDropped > 0
? priorDisplayed.slice(formatTruncationMarker(priorDropped).length)
: priorDisplayed;
const combinedTail = priorTail + addition;
if (priorDropped === 0 && combinedTail.length <= max) {
return { displayed: combinedTail, dropped: 0 };
}
// The marker's own text grows (rarely) as `dropped`'s digit count grows,
// which shrinks the budget left for the tail, which can in turn increase
// `dropped` further — a handful of iterations is always enough to reach a
// fixed point for any realistic character count.
let dropped = priorDropped;
for (let i = 0; i < 8; i += 1) {
const budget = Math.max(0, max - formatTruncationMarker(dropped).length);
const nextDropped = priorDropped + Math.max(0, combinedTail.length - budget);
if (nextDropped === dropped) break;
dropped = nextDropped;
}
const marker = formatTruncationMarker(dropped);
const budget = Math.max(0, max - marker.length);
const tail = combinedTail.slice(Math.max(0, combinedTail.length - budget));
return { displayed: marker + tail, dropped };
} }
/** Pushes `item` onto `arr`, dropping the oldest entries once `max` is exceeded. */ /** Pushes `item` onto `arr`, dropping the oldest entries once `max` is exceeded. */
@@ -72,6 +131,17 @@ function isUnrecoverableStartupFailure(state: ChatConnectionState): boolean {
return state.conversationId === null && state.pendingSend; return state.conversationId === null && state.pendingSend;
} }
/** True when a scoped `agent:end`/`error` is a recognized-stale terminal: a
* send is genuinely in flight (`sending`) but the current turn's own
* ack/start has not armed `armedTurnToken` yet — so this event must be a
* leftover from an earlier turn on the same conversation, not this one's own
* terminal. Recognizing this must make the event a true no-op: it may not
* finalize/clear transient state (text, thinking, streaming, error,
* approvalRequestPending) that belongs to the still in-flight turn. */
function isRecognizedStaleTerminal(state: ChatConnectionState): boolean {
return state.sending && state.armedTurnToken !== state.turnToken;
}
/** Deterministic, unique-per-event fallback id for a malformed `toolCallId`. /** Deterministic, unique-per-event fallback id for a malformed `toolCallId`.
* Sourced from a monotonically increasing counter carried in state (`toolSeq`) * Sourced from a monotonically increasing counter carried in state (`toolSeq`)
* rather than `tools.length`, so it stays collision-free even once `tools` is * rather than `tools.length`, so it stays collision-free even once `tools` is
@@ -166,7 +236,14 @@ export interface ChatConnectionState {
ack: MessageAckPayload | null; ack: MessageAckPayload | null;
streaming: boolean; streaming: boolean;
text: string; text: string;
/** Total original `agent:text` characters dropped so far by the
* MAX_STREAM_CHARS cap on `text` — tracked separately from `text` itself
* so the honest cumulative count survives across multiple appends without
* re-parsing any marker embedded in the displayed string. */
textDroppedChars: number;
thinking: string; thinking: string;
/** Same accounting as `textDroppedChars`, for `thinking`. */
thinkingDroppedChars: number;
tools: ToolCallState[]; tools: ToolCallState[];
usage: SessionUsagePayload | null; usage: SessionUsagePayload | null;
sessionInfo: SessionInfoPayload | null; sessionInfo: SessionInfoPayload | null;
@@ -189,6 +266,20 @@ export interface ChatConnectionState {
* tools remain, so fallback ids stay unique across the MAX_TOOLS cap * tools remain, so fallback ids stay unique across the MAX_TOOLS cap
* boundary. */ * boundary. */
toolSeq: number; toolSeq: number;
/** Monotonically increasing id for the current in-flight turn, minted at
* local send time. Wire payloads (agent:end/error) carry only a
* conversationId, not a turn-scoped correlation id — two different turns
* on the SAME conversation are indistinguishable on the wire, so this
* local counter is what actually tells them apart. */
turnToken: number;
/** The `turnToken` value (if any) that has been armed to accept a normal
* terminal release, set only by an accepted (conversation-scoped)
* message:ack or agent:start for the CURRENT token. A same-conversation
* agent:end/error may only set `sending: false` when this equals
* `turnToken` — otherwise it is a stale terminal event from an earlier
* turn on the same conversation and must not unlock a still-in-flight
* later turn. Fails closed: an unarmed token can never be unlocked. */
armedTurnToken: number | null;
} }
export interface ChatConnectionActions { export interface ChatConnectionActions {
@@ -212,7 +303,9 @@ const initialState: ChatConnectionState = {
ack: null, ack: null,
streaming: false, streaming: false,
text: '', text: '',
textDroppedChars: 0,
thinking: '', thinking: '',
thinkingDroppedChars: 0,
tools: [], tools: [],
usage: null, usage: null,
sessionInfo: null, sessionInfo: null,
@@ -226,6 +319,8 @@ const initialState: ChatConnectionState = {
messages: [], messages: [],
messageSeq: 0, messageSeq: 0,
toolSeq: 0, toolSeq: 0,
turnToken: 0,
armedTurnToken: null,
}; };
type Action = type Action =
@@ -274,7 +369,14 @@ function resolveScopedConversation(
return { active: true, state }; return { active: true, state };
} }
if (state.conversationId === null && state.pendingSend) { if (state.conversationId === null && state.pendingSend) {
return { active: true, state: { ...state, conversationId, pendingSend: false } }; // The very first scoped event for a brand-new conversation can only belong
// to the currently in-flight turn — no prior same-conversation turn exists to
// be confused with. Arm the current turnToken so a terminal event (error,
// agent:end) arriving before ack/start can still properly release the send lock.
return {
active: true,
state: { ...state, conversationId, pendingSend: false, armedTurnToken: state.turnToken },
};
} }
return { active: false, state: null }; return { active: false, state: null };
} }
@@ -311,37 +413,61 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
conversationId, conversationId,
pendingSend: false, pendingSend: false,
ack: sanitizeAck(payload, conversationId), ack: sanitizeAck(payload, conversationId),
// The very first scoped event ever received for a brand-new
// conversation can only belong to the currently in-flight turn —
// no prior same-conversation turn can exist to be confused with.
armedTurnToken: state.turnToken,
}; };
} }
if (payload.conversationId !== state.conversationId) return state; if (payload.conversationId !== state.conversationId) return state;
return { ...state, ack: sanitizeAck(payload, state.conversationId) }; return {
...state,
ack: sanitizeAck(payload, state.conversationId),
armedTurnToken: state.turnToken,
};
} }
case 'server/agent:start': { case 'server/agent:start': {
const resolved = resolveScopedConversation(state, action.payload.conversationId); const resolved = resolveScopedConversation(state, action.payload.conversationId);
if (!resolved.active) return state; if (!resolved.active) return state;
return { ...resolved.state, streaming: true, text: '', thinking: '', tools: [], error: null }; return {
...resolved.state,
streaming: true,
text: '',
thinking: '',
textDroppedChars: 0,
thinkingDroppedChars: 0,
tools: [],
error: null,
armedTurnToken: resolved.state.turnToken,
};
} }
case 'server/agent:text': { case 'server/agent:text': {
const resolved = resolveScopedConversation(state, action.payload.conversationId); const resolved = resolveScopedConversation(state, action.payload.conversationId);
if (!resolved.active) return state; if (!resolved.active) return state;
return { const capped = capAppendStream(
...resolved.state, resolved.state.text,
text: capAppendString(resolved.state.text, asString(action.payload.text), MAX_STREAM_CHARS), resolved.state.textDroppedChars,
}; asString(action.payload.text),
MAX_STREAM_CHARS,
);
return { ...resolved.state, text: capped.displayed, textDroppedChars: capped.dropped };
} }
case 'server/agent:thinking': { case 'server/agent:thinking': {
const resolved = resolveScopedConversation(state, action.payload.conversationId); const resolved = resolveScopedConversation(state, action.payload.conversationId);
if (!resolved.active) return state; if (!resolved.active) return state;
return { const capped = capAppendStream(
...resolved.state,
thinking: capAppendString(
resolved.state.thinking, resolved.state.thinking,
resolved.state.thinkingDroppedChars,
asString(action.payload.text), asString(action.payload.text),
MAX_STREAM_CHARS, MAX_STREAM_CHARS,
), );
return {
...resolved.state,
thinking: capped.displayed,
thinkingDroppedChars: capped.dropped,
}; };
} }
@@ -413,12 +539,20 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
sending: false, sending: false,
streaming: false, streaming: false,
approvalRequestPending: false, approvalRequestPending: false,
armedTurnToken: null,
error: CONVERSATION_START_FAILURE, error: CONVERSATION_START_FAILURE,
}; };
} }
return state; return state;
} }
const next = resolved.state; const next = resolved.state;
if (isRecognizedStaleTerminal(next)) {
// A same-conversation agent:end recognized as stale must be a true
// no-op — it must not finalize the stale turn's leftover
// text/thinking into a message or reset streaming, both of which
// legitimately belong to the still in-flight later turn.
return next;
}
const hasContent = next.text.length > 0 || next.thinking.length > 0; const hasContent = next.text.length > 0 || next.thinking.length > 0;
const messages = hasContent const messages = hasContent
? capPush( ? capPush(
@@ -435,12 +569,23 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
MAX_MESSAGES, MAX_MESSAGES,
) )
: next.messages; : next.messages;
// The wire carries only conversationId, not a turn id — a stale
// agent:end left over from an EARLIER turn on this SAME conversation
// is indistinguishable from this turn's own by conversationId alone.
// Only release `sending` when this turn's own accepted ack/start armed
// the current token; otherwise this is treated as a stale echo and the
// lock stays held (fail closed) so it cannot unlock a later, still
// in-flight turn.
const canRelease = next.armedTurnToken === next.turnToken;
return { return {
...next, ...next,
streaming: false, streaming: false,
sending: false, sending: canRelease ? false : next.sending,
armedTurnToken: canRelease ? null : next.armedTurnToken,
text: '', text: '',
thinking: '', thinking: '',
textDroppedChars: 0,
thinkingDroppedChars: 0,
usage: payload.usage ?? next.usage, usage: payload.usage ?? next.usage,
messages, messages,
messageSeq: hasContent ? next.messageSeq + 1 : next.messageSeq, messageSeq: hasContent ? next.messageSeq + 1 : next.messageSeq,
@@ -448,9 +593,23 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
} }
case 'server/session:info': { case 'server/session:info': {
const resolved = resolveScopedConversation(state, action.payload.conversationId); const { payload } = action;
const resolved = resolveScopedConversation(state, payload.conversationId);
if (!resolved.active) return state; if (!resolved.active) return state;
return { ...resolved.state, sessionInfo: action.payload }; return {
...resolved.state,
sessionInfo: {
...payload,
// A hostile/malfunctioning gateway can flood this list; cap it
// before it ever reaches state so a render site (e.g. the
// thinking-level <select>) can never be forced to lay out an
// unbounded number of options.
availableThinkingLevels: asStringArray(payload.availableThinkingLevels).slice(
0,
MAX_MANIFEST_ITEMS,
),
},
};
} }
case 'server/commands:manifest': { case 'server/commands:manifest': {
@@ -512,12 +671,24 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
case 'server/system:reload': { case 'server/system:reload': {
const { payload } = action; const { payload } = action;
// Computed once and reused for both `systemReload` and `manifest` —
// spreading the raw `payload` into `systemReload` first and only
// capping the copy handed to `manifest` left the raw, uncapped
// commands/skills sitting in `state.systemReload`. The sanitized
// fields are placed after the spread below so they always win.
const commands = capList<CommandDef>(payload.commands, MAX_MANIFEST_ITEMS);
const skills = capList<SkillCommandDef>(payload.skills, MAX_MANIFEST_ITEMS);
return { return {
...state, ...state,
systemReload: { ...payload, message: asString(payload.message, 'Commands reloaded.') }, systemReload: {
...payload,
commands,
skills,
message: asString(payload.message, 'Commands reloaded.'),
},
manifest: { manifest: {
commands: capList<CommandDef>(payload.commands, MAX_MANIFEST_ITEMS), commands,
skills: capList<SkillCommandDef>(payload.skills, MAX_MANIFEST_ITEMS), skills,
version: state.manifest?.version ?? 0, version: state.manifest?.version ?? 0,
}, },
}; };
@@ -534,16 +705,31 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
sending: false, sending: false,
streaming: false, streaming: false,
approvalRequestPending: false, approvalRequestPending: false,
armedTurnToken: null,
error: CONVERSATION_START_FAILURE, error: CONVERSATION_START_FAILURE,
}; };
} }
return state; return state;
} }
const next = resolved.state;
if (isRecognizedStaleTerminal(next)) {
// Same true-no-op guard as `server/agent:end` above: a
// same-conversation error recognized as stale must not display/store
// its message, touch streaming, or clear approvalRequestPending —
// doing so would re-arm the approve UI for a request that belongs to
// the still in-flight later turn while the first remains outstanding.
return next;
}
// Same fail-closed turn-token guard as `server/agent:end` above: a
// same-conversation error left over from an earlier, already-finished
// turn must not release a later turn's still-in-flight send lock.
const canRelease = next.armedTurnToken === next.turnToken;
return { return {
...resolved.state, ...next,
error: asString(payload.error, 'An error occurred.'), error: asString(payload.error, 'An error occurred.'),
streaming: false, streaming: false,
sending: false, sending: canRelease ? false : next.sending,
armedTurnToken: canRelease ? null : next.armedTurnToken,
// A turn-scoped error also invalidates any approval request awaiting // A turn-scoped error also invalidates any approval request awaiting
// a response — the gateway that just errored is unlikely to still // a response — the gateway that just errored is unlikely to still
// answer it, and the synchronous approveLockRef mirrors this field. // answer it, and the synchronous approveLockRef mirrors this field.
@@ -566,6 +752,12 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
messageSeq: state.messageSeq + 1, messageSeq: state.messageSeq + 1,
error: null, error: null,
sending: true, sending: true,
// Sending a new turn mints a fresh token and immediately clears
// terminal eligibility — only THIS turn's own accepted ack/start may
// arm it, so a same-conversation terminal left over from the turn
// that just finished can never be mistaken for this one's.
turnToken: state.turnToken + 1,
armedTurnToken: null,
pendingSend: state.conversationId === null ? true : state.pendingSend, pendingSend: state.conversationId === null ? true : state.pendingSend,
}; };
} }
@@ -579,6 +771,7 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
sending: false, sending: false,
pendingSend: false, pendingSend: false,
approvalRequestPending: false, approvalRequestPending: false,
armedTurnToken: null,
}; };
} }
@@ -766,12 +959,24 @@ export function useChatConnection(): ChatConnectionValue {
if (approval?.success !== true) return; if (approval?.success !== true) return;
if (typeof approval.approvalId !== 'string' || approval.approvalId.length === 0) return; if (typeof approval.approvalId !== 'string' || approval.approvalId.length === 0) return;
if (!pendingApproval || pendingApproval.command !== approval.command) return; if (!pendingApproval || pendingApproval.command !== approval.command) return;
if (executedApprovalIds.current.has(approval.approvalId)) return; if (executedApprovalIds.current.has(approval.approvalId)) {
dispatch({ type: 'local/consume-approval' });
return;
}
if (executedApprovalIds.current.size >= MAX_EXECUTED_APPROVAL_IDS) { if (executedApprovalIds.current.size >= MAX_EXECUTED_APPROVAL_IDS) {
// Bounded dedup set: evict the oldest entry (Sets iterate in // Security tradeoff, chosen deliberately: this dedup set never
// insertion order) so this can never grow without limit. // evicts. Evicting the oldest entry to make room (the old behavior)
const oldest = executedApprovalIds.current.values().next().value; // would let a replay of that forgotten ID execute again once it
if (oldest !== undefined) executedApprovalIds.current.delete(oldest); // scrolled out of the set — a false negative that lets a privileged
// command run twice. Once the set is full, every *unseen* approval
// is denied instead. The mounted hook remains fail-closed until
// unmounted (lifecycle reset), not recoverable by re-approving — a
// false positive/availability cost but a genuine replay of any ID
// ever seen by this hook can never execute a second time. Consuming
// (rather than silently no-op'ing) releases the UI lock so the
// denial is visible/recoverable by remounting.
dispatch({ type: 'local/consume-approval' });
return;
} }
executedApprovalIds.current.add(approval.approvalId); executedApprovalIds.current.add(approval.approvalId);
const socket = getSocket(); const socket = getSocket();
@@ -2,7 +2,16 @@ import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client'; import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom'; import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { ChatRouteErrorBoundary } from './chat-error-boundary';
const { useSessionMock } = vi.hoisted(() => ({
useSessionMock: vi.fn(),
}));
vi.mock('@/lib/auth-client', () => ({
useSession: useSessionMock,
}));
import { routes } from '@/routes';
beforeAll(() => { beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
@@ -19,6 +28,25 @@ function Boom(): never {
throw new Error('render blew up'); throw new Error('render blew up');
} }
/** Recursively clones the real exported route table, replacing only the
* `/chat` route's `element` with `<Boom />` — every other route (including
* the real `AuthGuard` nesting and the real `/chat` `errorElement`) is left
* exactly as exported. This is what makes the test fail if a future change
* removes the real route's `errorElement`, unlike a hand-built independent
* route tree that could drift from production undetected. */
function replaceChatElementWithBoom(nodes: RouteObject[]): RouteObject[] {
return nodes.map((node) => {
const cloned: RouteObject = { ...node };
if (cloned.path === '/chat') {
cloned.element = <Boom />;
}
if (cloned.children) {
cloned.children = replaceChatElementWithBoom(cloned.children);
}
return cloned;
});
}
let root: Root | null; let root: Root | null;
let container: HTMLElement; let container: HTMLElement;
@@ -28,13 +56,14 @@ afterEach(async () => {
}); });
document.body.replaceChildren(); document.body.replaceChildren();
root = null; root = null;
useSessionMock.mockReset();
}); });
describe('ChatRouteErrorBoundary', () => { describe('ChatRouteErrorBoundary', () => {
it('renders a recoverable, non-blank fallback when the /chat route element throws during render', async () => { it('renders a recoverable, non-blank fallback when the /chat route element throws during render', async () => {
const routeObjects: RouteObject[] = [ useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
{ path: '/chat', element: <Boom />, errorElement: <ChatRouteErrorBoundary /> },
]; const routeObjects = replaceChatElementWithBoom(routes);
const router = createMemoryRouter(routeObjects, { initialEntries: ['/chat'] }); const router = createMemoryRouter(routeObjects, { initialEntries: ['/chat'] });
container = document.createElement('div'); container = document.createElement('div');
+41 -4
View File
@@ -2,6 +2,7 @@ import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client'; import { createRoot, type Root } from 'react-dom/client';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket'; import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket';
import { MAX_MANIFEST_ITEMS } from '@/spa/chat/limits';
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({ const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
getSocketMock: vi.fn(), getSocketMock: vi.fn(),
@@ -233,7 +234,7 @@ describe('ChatPage', () => {
}); });
}); });
it('shows visible alert surfaces for a server error and a stable failure copy for a failed command result', async () => { it('shows visible alert surfaces for a server error and the structured contract reason for a failed command result', async () => {
await act(async () => { await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' }); fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
@@ -241,16 +242,52 @@ describe('ChatPage', () => {
conversationId: 'c1', conversationId: 'c1',
command: 'model', command: 'model',
success: false, success: false,
message: 'raw internal detail: stack trace at line 42', message: 'Unknown model',
}); });
}); });
const alerts = [...container.querySelectorAll('[role="alert"]')]; const alerts = [...container.querySelectorAll('[role="alert"]')];
const alertText = alerts.map((node) => node.textContent).join(' '); const alertText = alerts.map((node) => node.textContent).join(' ');
expect(alertText).toContain('The model is unavailable'); expect(alertText).toContain('The model is unavailable');
// Sanitized client copy, not the raw server-provided detail. // The structured, contract-provided denial reason is visibly rendered.
expect(alertText).toContain('Unknown model');
});
it('falls back to a stable "Command failed." copy when a failed command result has no usable message', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmitRaw('command:result', {
conversationId: 'c1',
command: 'model',
success: false,
message: { bad: 'object' },
});
});
const alerts = [...container.querySelectorAll('[role="alert"]')];
const alertText = alerts.map((node) => node.textContent).join(' ');
expect(alertText).toContain('Command failed.'); expect(alertText).toContain('Command failed.');
expect(alertText).not.toContain('raw internal detail'); });
it('caps availableThinkingLevels before storing and rendering a hostile session payload', async () => {
const hostileLevels = Array.from({ length: MAX_MANIFEST_ITEMS + 50 }, (_, i) => `level-${i}`);
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
fake.serverEmit('session:info', {
conversationId: 'c1',
provider: 'anthropic',
modelId: 'claude',
thinkingLevel: 'level-0',
availableThinkingLevels: hostileLevels,
});
});
const select = container.querySelector(
'select[aria-label="Thinking level"]',
) as HTMLSelectElement;
expect(select).toBeTruthy();
expect(select.options.length).toBeLessThanOrEqual(MAX_MANIFEST_ITEMS);
}); });
it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => { it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => {
+3
View File
@@ -253,6 +253,9 @@ importers:
'@mosaicstack/design-tokens': '@mosaicstack/design-tokens':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/design-tokens version: link:../../packages/design-tokens
'@mosaicstack/types':
specifier: workspace:^
version: link:../../packages/types
better-auth: better-auth:
specifier: ^1.5.5 specifier: ^1.5.5
version: 1.5.5([email protected])([email protected])([email protected](@electric-sql/[email protected])(@opentelemetry/[email protected])(@types/[email protected])(@types/[email protected])([email protected])([email protected])([email protected]))([email protected]([email protected]))([email protected](@opentelemetry/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])(@types/[email protected])([email protected](@noble/[email protected]))([email protected])) version: 1.5.5([email protected])([email protected])([email protected](@electric-sql/[email protected])(@opentelemetry/[email protected])(@types/[email protected])(@types/[email protected])([email protected])([email protected])([email protected]))([email protected]([email protected]))([email protected](@opentelemetry/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])(@types/[email protected])([email protected](@noble/[email protected]))([email protected]))