fix(web): close P3 chat re-review findings
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
const capped = capAppendStream(
|
||||||
|
resolved.state.thinking,
|
||||||
|
resolved.state.thinkingDroppedChars,
|
||||||
|
asString(action.payload.text),
|
||||||
|
MAX_STREAM_CHARS,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...resolved.state,
|
...resolved.state,
|
||||||
thinking: capAppendString(
|
thinking: capped.displayed,
|
||||||
resolved.state.thinking,
|
thinkingDroppedChars: capped.dropped,
|
||||||
asString(action.payload.text),
|
|
||||||
MAX_STREAM_CHARS,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Generated
+3
Reference in New Issue
Block a user