fix(web): harden typed SPA chat lifecycle
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
b2e005f2b4
commit
caebf9ef70
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/design-tokens": "workspace:^",
|
"@mosaicstack/design-tokens": "workspace:^",
|
||||||
|
"@mosaicstack/types": "workspace:^",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
|
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
|
||||||
// @mosaicstack/types' source.
|
// the public `@mosaicstack/types` package. `import type` is erased at compile
|
||||||
//
|
// time, so this introduces no runtime dependency — it only reuses the exact
|
||||||
// `apps/web` does not declare `@mosaicstack/types` as a package dependency (P3 scope
|
// payload shapes instead of redeclaring them.
|
||||||
// forbids editing package manifests/lockfiles), so these are type-only imports
|
|
||||||
// resolved directly against the package source. `import type` is erased at compile
|
|
||||||
// time, so no runtime dependency is introduced — this only reuses the exact payload
|
|
||||||
// shapes instead of redeclaring them.
|
|
||||||
//
|
|
||||||
// This deliberately imports the narrow `chat/events` and `commands/index` entry
|
|
||||||
// points rather than the package's full `src/index` barrel: the barrel also re-exports
|
|
||||||
// class-validator/class-transformer decorated DTOs (chat.dto.ts, reflection.dto.ts,
|
|
||||||
// connector-lease.dto.ts) that require compiler options apps/web's tsconfig does not
|
|
||||||
// set, which breaks `tsc --noEmit` here even though only types are imported.
|
|
||||||
import type { Socket } from 'socket.io-client';
|
import type { Socket } from 'socket.io-client';
|
||||||
import type {
|
import type {
|
||||||
AbortPayload,
|
AbortPayload,
|
||||||
@@ -21,6 +11,9 @@ import type {
|
|||||||
AgentThinkingPayload,
|
AgentThinkingPayload,
|
||||||
ChatMessagePayload,
|
ChatMessagePayload,
|
||||||
ClientToServerEvents,
|
ClientToServerEvents,
|
||||||
|
CommandDef,
|
||||||
|
CommandManifest,
|
||||||
|
CommandManifestPayload,
|
||||||
ErrorPayload,
|
ErrorPayload,
|
||||||
MessageAckPayload,
|
MessageAckPayload,
|
||||||
RoutingDecisionInfo,
|
RoutingDecisionInfo,
|
||||||
@@ -28,19 +21,14 @@ import type {
|
|||||||
SessionInfoPayload,
|
SessionInfoPayload,
|
||||||
SessionUsagePayload,
|
SessionUsagePayload,
|
||||||
SetThinkingPayload,
|
SetThinkingPayload,
|
||||||
ToolEndPayload,
|
|
||||||
ToolStartPayload,
|
|
||||||
} from '../../../../packages/types/src/chat/events';
|
|
||||||
import type {
|
|
||||||
CommandDef,
|
|
||||||
CommandManifest,
|
|
||||||
CommandManifestPayload,
|
|
||||||
SkillCommandDef,
|
SkillCommandDef,
|
||||||
SlashCommandApprovalResultPayload,
|
SlashCommandApprovalResultPayload,
|
||||||
SlashCommandPayload,
|
SlashCommandPayload,
|
||||||
SlashCommandResultPayload,
|
SlashCommandResultPayload,
|
||||||
SystemReloadPayload,
|
SystemReloadPayload,
|
||||||
} from '../../../../packages/types/src/commands/index';
|
ToolEndPayload,
|
||||||
|
ToolStartPayload,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AbortPayload,
|
AbortPayload,
|
||||||
|
|||||||
@@ -10,30 +10,32 @@ vi.mock('socket.io-client', () => ({
|
|||||||
|
|
||||||
import { destroySocket, getSocket } from './socket';
|
import { destroySocket, getSocket } from './socket';
|
||||||
|
|
||||||
|
function createMockSocket(): {
|
||||||
|
on: ReturnType<typeof vi.fn>;
|
||||||
|
offAny: ReturnType<typeof vi.fn>;
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
const mockSocket = {
|
||||||
|
on: vi.fn(() => mockSocket),
|
||||||
|
offAny: vi.fn(() => mockSocket),
|
||||||
|
disconnect: vi.fn(() => mockSocket),
|
||||||
|
};
|
||||||
|
return mockSocket;
|
||||||
|
}
|
||||||
|
|
||||||
describe('chat socket', () => {
|
describe('chat socket', () => {
|
||||||
let disconnectHandler: (() => void) | undefined;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
disconnectHandler = undefined;
|
|
||||||
ioMock.mockReset();
|
ioMock.mockReset();
|
||||||
|
// A fresh object per io() call so identity assertions (same singleton vs.
|
||||||
const mockSocket = {
|
// a genuinely new instance) are meaningful.
|
||||||
on: vi.fn((event: string, handler: () => void) => {
|
ioMock.mockImplementation(() => createMockSocket());
|
||||||
if (event === 'disconnect') disconnectHandler = handler;
|
|
||||||
return mockSocket;
|
|
||||||
}),
|
|
||||||
offAny: vi.fn(() => mockSocket),
|
|
||||||
disconnect: vi.fn(() => mockSocket),
|
|
||||||
};
|
|
||||||
|
|
||||||
ioMock.mockReturnValue(mockSocket);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
destroySocket();
|
destroySocket();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates one same-origin /chat namespace socket until it disconnects', () => {
|
it('creates one same-origin /chat namespace socket', () => {
|
||||||
const first = getSocket();
|
const first = getSocket();
|
||||||
const second = getSocket();
|
const second = getSocket();
|
||||||
|
|
||||||
@@ -44,9 +46,28 @@ describe('chat socket', () => {
|
|||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
disconnectHandler?.();
|
it('keeps the same singleton instance across a transient disconnect', () => {
|
||||||
getSocket();
|
const first = getSocket();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// simply calling getSocket() again after a "disconnect" must still
|
||||||
|
// return the same instance.
|
||||||
|
const second = getSocket();
|
||||||
|
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(ioMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only creates a new singleton after an explicit destroySocket()', () => {
|
||||||
|
const first = getSocket();
|
||||||
|
|
||||||
|
destroySocket();
|
||||||
|
const second = getSocket();
|
||||||
|
|
||||||
|
expect(second).not.toBe(first);
|
||||||
expect(ioMock).toHaveBeenCalledTimes(2);
|
expect(ioMock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ export function getSocket(): ChatSocket {
|
|||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
}) as unknown as ChatSocket;
|
}) as unknown as ChatSocket;
|
||||||
|
|
||||||
// Reset singleton reference when socket is fully closed so the next
|
// A transient `disconnect` (network blip, server restart) must NOT null
|
||||||
// getSocket() call creates a fresh instance instead of returning a
|
// the singleton: socket.io-client auto-reconnects this same instance,
|
||||||
// closed/dead socket.
|
// and its listeners stay registered across that reconnect. Nulling here
|
||||||
socket.on('disconnect', () => {
|
// previously orphaned those listeners on the next getSocket() call by
|
||||||
socket = null;
|
// handing back a brand-new, unconnected instance. Only destroySocket()
|
||||||
});
|
// (an explicit, intentional teardown) may reset the singleton.
|
||||||
}
|
}
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { LoginPage } from '@/spa/pages/login';
|
|||||||
import { RegisterPage } from '@/spa/pages/register';
|
import { RegisterPage } from '@/spa/pages/register';
|
||||||
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
||||||
import { ChatPage } from '@/spa/pages/chat';
|
import { ChatPage } from '@/spa/pages/chat';
|
||||||
|
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
|
||||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ export const routes: RouteObject[] = [
|
|||||||
element: <AuthGuard />,
|
element: <AuthGuard />,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||||
{ path: '/chat', element: <ChatPage /> },
|
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||||
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
||||||
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
||||||
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { CommandsPanel } from './commands-panel';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement | null;
|
||||||
|
|
||||||
|
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CommandsPanel', () => {
|
||||||
|
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={{
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
// Free-text server message claims a different, less alarming target
|
||||||
|
// than what will actually be sent — the UI must not rely on this.
|
||||||
|
message: 'This will only affect the staging environment.',
|
||||||
|
}}
|
||||||
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The exact frozen combined action is visible...
|
||||||
|
expect(container?.textContent).toContain('/deploy');
|
||||||
|
expect(container?.textContent).toContain('prod');
|
||||||
|
// ...and the misleading server free-text is never shown next to it.
|
||||||
|
expect(container?.textContent).not.toContain('staging environment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when a manifest commands entry is null', async () => {
|
||||||
|
const manifest = {
|
||||||
|
commands: [
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
aliases: [],
|
||||||
|
description: 'Change the active model',
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [null],
|
||||||
|
version: 1,
|
||||||
|
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={manifest}
|
||||||
|
results={[]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={{
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
}}
|
||||||
|
pendingApproval={{ command: 'deploy' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent?.toLowerCase()).toContain('no args');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders skills from a skills-only manifest', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={{
|
||||||
|
commands: [],
|
||||||
|
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
|
||||||
|
version: 1,
|
||||||
|
}}
|
||||||
|
results={[]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('brave-search');
|
||||||
|
expect(container?.textContent).toContain('Search the web');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
|
||||||
|
const approval = {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: { truthy: 'object' },
|
||||||
|
approvalId: { also: 'object' },
|
||||||
|
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={approval}
|
||||||
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
|
||||||
|
button.textContent?.includes('Run approved command'),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
|
||||||
|
const manifest = {
|
||||||
|
commands: 'not-an-array',
|
||||||
|
skills: null,
|
||||||
|
version: 1,
|
||||||
|
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={manifest}
|
||||||
|
results={[]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +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 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
|
||||||
|
* detail, which could leak internal error text to the user. */
|
||||||
|
const COMMAND_FAILURE_COPY = 'Command failed.';
|
||||||
|
|
||||||
interface CommandsPanelProps {
|
interface CommandsPanelProps {
|
||||||
manifest: CommandManifest | null;
|
manifest: CommandManifest | null;
|
||||||
results: SlashCommandResultPayload[];
|
results: SlashCommandResultPayload[];
|
||||||
@@ -30,19 +35,39 @@ export function CommandsPanel({
|
|||||||
const [command, setCommand] = useState('');
|
const [command, setCommand] = useState('');
|
||||||
const [args, setArgs] = useState('');
|
const [args, setArgs] = useState('');
|
||||||
|
|
||||||
|
// Defense-in-depth: the reducer already normalizes success/approvalId
|
||||||
|
// before storing `approval`, but a matching command string alone must
|
||||||
|
// never be trusted here either — require the literal boolean `true` and a
|
||||||
|
// non-empty string approvalId, not merely truthy values.
|
||||||
const canRunApproved =
|
const canRunApproved =
|
||||||
!!approval?.success &&
|
approval?.success === true &&
|
||||||
!!approval.approvalId &&
|
typeof approval.approvalId === 'string' &&
|
||||||
|
approval.approvalId.length > 0 &&
|
||||||
!!pendingApproval &&
|
!!pendingApproval &&
|
||||||
pendingApproval.command === approval.command;
|
pendingApproval.command === approval.command;
|
||||||
|
|
||||||
|
// A manifest arrives from the server as untyped JSON at runtime — guard
|
||||||
|
// both collections before mapping so a malformed manifest cannot throw.
|
||||||
|
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
|
||||||
|
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
||||||
{manifest && manifest.commands.length > 0 ? (
|
{commands.length > 0 ? (
|
||||||
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
||||||
{manifest.commands.map((cmd) => (
|
{commands.map((cmd, index) => (
|
||||||
<li key={cmd.name}>
|
<li key={asString(cmd?.name) || `cmd-${index}`}>
|
||||||
<strong>/{cmd.name}</strong> — {cmd.description}
|
<strong>/{asString(cmd?.name)}</strong> — {asString(cmd?.description)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{skills.length > 0 ? (
|
||||||
|
<ul aria-label="Available skills" className="flex flex-col gap-1">
|
||||||
|
{skills.map((skill, index) => (
|
||||||
|
<li key={asString(skill?.name) || `skill-${index}`}>
|
||||||
|
<strong>/skill:{asString(skill?.name)}</strong> — {asString(skill?.description)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -79,16 +104,24 @@ 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">
|
||||||
<span>
|
{/* Stable client copy only — never the server-controlled
|
||||||
{approval.success
|
approval.message or echoed approval.command as the primary
|
||||||
? `Approved: /${approval.command}`
|
confirmation. The frozen local pendingApproval below (not this
|
||||||
: `Approval denied: /${approval.command}`}
|
line) is the sole authoritative statement of what will run. */}
|
||||||
{approval.message ? ` — ${approval.message}` : ''}
|
<span>{approval.success ? 'Approved.' : 'Denied.'}</span>
|
||||||
</span>
|
{canRunApproved && pendingApproval ? (
|
||||||
{canRunApproved ? (
|
<>
|
||||||
<button type="button" onClick={onRunApproved}>
|
{/* Authoritative frozen local command+args — what the click below
|
||||||
Run approved command
|
will actually emit. The server's `approval` above is display-only
|
||||||
</button>
|
and must never be trusted to represent the executed payload. */}
|
||||||
|
<span>
|
||||||
|
Will run: /{pendingApproval.command}{' '}
|
||||||
|
{pendingApproval.args ? pendingApproval.args : '(no args)'}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onRunApproved}>
|
||||||
|
Run approved command
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -97,8 +130,12 @@ export function CommandsPanel({
|
|||||||
<ul aria-label="Command results" className="flex flex-col gap-1">
|
<ul aria-label="Command results" className="flex flex-col gap-1">
|
||||||
{results.map((result, index) => (
|
{results.map((result, index) => (
|
||||||
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
||||||
/{result.command}: {result.success ? 'success' : 'failed'}
|
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
|
||||||
{result.message ? ` — ${result.message}` : ''}
|
{result.success
|
||||||
|
? typeof result.message === 'string' && result.message
|
||||||
|
? ` — ${result.message}`
|
||||||
|
: ''
|
||||||
|
: ` — ${COMMAND_FAILURE_COPY}`}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ interface ComposerProps {
|
|||||||
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
|
/** True from local send time through server turn startup/ack and
|
||||||
|
* throughout streaming — a superset of `streaming` that also covers the
|
||||||
|
* pre-ack window where a second send could otherwise slip through. */
|
||||||
|
sending: boolean;
|
||||||
hasConversation: boolean;
|
hasConversation: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,14 +15,16 @@ export function Composer({
|
|||||||
onSend,
|
onSend,
|
||||||
onStop,
|
onStop,
|
||||||
streaming,
|
streaming,
|
||||||
|
sending,
|
||||||
hasConversation,
|
hasConversation,
|
||||||
}: ComposerProps): ReactElement {
|
}: ComposerProps): ReactElement {
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [provider, setProvider] = useState('');
|
const [provider, setProvider] = useState('');
|
||||||
const [modelId, setModelId] = useState('');
|
const [modelId, setModelId] = useState('');
|
||||||
|
const busy = streaming || sending;
|
||||||
|
|
||||||
function submit(): void {
|
function submit(): void {
|
||||||
if (streaming) return;
|
if (busy) return;
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
onSend({
|
onSend({
|
||||||
@@ -72,7 +78,7 @@ export function Composer({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!content.trim() || streaming}
|
disabled={!content.trim() || busy}
|
||||||
className="rounded px-3 py-2 text-sm font-medium"
|
className="rounded px-3 py-2 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Send
|
Send
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Bounds on server-fed chat state. A hostile or malfunctioning gateway can
|
||||||
|
* flood any of these collections; caps keep memory/render cost flat instead
|
||||||
|
* of growing unboundedly for the lifetime of the connection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Max characters retained for the in-flight streamed text/thinking buffers. */
|
||||||
|
export const MAX_STREAM_CHARS = 20_000;
|
||||||
|
/** Max transcript turns retained (oldest dropped first). */
|
||||||
|
export const MAX_MESSAGES = 500;
|
||||||
|
/** Max tool-call entries (including anomaly entries) retained per turn history. */
|
||||||
|
export const MAX_TOOLS = 200;
|
||||||
|
/** Max slash-command results retained. */
|
||||||
|
export const MAX_COMMAND_RESULTS = 200;
|
||||||
|
/** Max commands/skills accepted from a single manifest push. */
|
||||||
|
export const MAX_MANIFEST_ITEMS = 500;
|
||||||
|
/** Max executed approval IDs remembered for single-flight dedup. */
|
||||||
|
export const MAX_EXECUTED_APPROVAL_IDS = 200;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Socket.IO payloads are only statically typed at the call site — a
|
||||||
|
* misbehaving or compromised gateway can send anything at runtime. These
|
||||||
|
* guards protect the dereference sites that would otherwise throw (`.map` on
|
||||||
|
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
||||||
|
* child.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function asString(value: unknown, fallback = ''): string {
|
||||||
|
return typeof value === 'string' ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asFiniteNumber(value: unknown, fallback = 0): number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like `asFiniteNumber`, but returns `null` on failure instead of a numeric
|
||||||
|
* fallback — callers that must not fabricate a plausible-looking value (e.g.
|
||||||
|
* `0 tokens` / `$0.0000` for genuinely unknown usage) use this to render an
|
||||||
|
* honest "unavailable" label instead. */
|
||||||
|
export function asFiniteNumberOrNull(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asStringArray(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single point of truth for what counts as a valid conversation ID
|
||||||
|
* anywhere a scoped server event may adopt one into state — a non-empty
|
||||||
|
* string, nothing else. Every site that establishes or compares
|
||||||
|
* `state.conversationId` against a raw socket payload must route through
|
||||||
|
* this guard so a malformed first frame (null/object/number/empty string)
|
||||||
|
* can never be adopted verbatim. */
|
||||||
|
export function asConversationId(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
@@ -1,41 +1,59 @@
|
|||||||
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 { asString, asStringArray } from './runtime-guards';
|
||||||
|
|
||||||
interface SessionPanelProps {
|
interface SessionPanelProps {
|
||||||
sessionInfo: SessionInfoPayload | null;
|
sessionInfo: SessionInfoPayload | null;
|
||||||
onSetThinking: (level: string) => void;
|
onSetThinking: (level: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THINKING_LEVEL_UNAVAILABLE = '';
|
||||||
|
|
||||||
export function SessionPanel({
|
export function SessionPanel({
|
||||||
sessionInfo,
|
sessionInfo,
|
||||||
onSetThinking,
|
onSetThinking,
|
||||||
}: SessionPanelProps): ReactElement | null {
|
}: SessionPanelProps): ReactElement | null {
|
||||||
if (!sessionInfo) return null;
|
if (!sessionInfo) return null;
|
||||||
|
|
||||||
|
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels);
|
||||||
|
const hasThinkingLevels = availableThinkingLevels.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label="Session info"
|
aria-label="Session info"
|
||||||
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
|
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
|
||||||
>
|
>
|
||||||
<span>{sessionInfo.provider}</span>
|
<span>{asString(sessionInfo.provider, 'unknown')}</span>
|
||||||
<span>{sessionInfo.modelId}</span>
|
<span>{asString(sessionInfo.modelId, 'unknown')}</span>
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<span>Thinking level</span>
|
<span>Thinking level</span>
|
||||||
<select
|
<select
|
||||||
aria-label="Thinking level"
|
aria-label="Thinking level"
|
||||||
value={sessionInfo.thinkingLevel}
|
value={
|
||||||
onChange={(event) => onSetThinking(event.target.value)}
|
hasThinkingLevels ? asString(sessionInfo.thinkingLevel) : THINKING_LEVEL_UNAVAILABLE
|
||||||
|
}
|
||||||
|
onChange={(event) => {
|
||||||
|
// The placeholder option is not a real, settable level — a
|
||||||
|
// malformed availableThinkingLevels list must never let the
|
||||||
|
// client emit set:thinking for it.
|
||||||
|
if (!hasThinkingLevels) return;
|
||||||
|
onSetThinking(event.target.value);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{sessionInfo.availableThinkingLevels.map((level) => (
|
{hasThinkingLevels ? (
|
||||||
<option key={level} value={level}>
|
availableThinkingLevels.map((level) => (
|
||||||
{level}
|
<option key={level} value={level}>
|
||||||
</option>
|
{level}
|
||||||
))}
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value={THINKING_LEVEL_UNAVAILABLE}>Thinking level unavailable</option>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{sessionInfo.routingDecision ? (
|
{sessionInfo.routingDecision ? (
|
||||||
<span title={sessionInfo.routingDecision.ruleName}>
|
<span title={asString(sessionInfo.routingDecision.ruleName)}>
|
||||||
{sessionInfo.routingDecision.reason}
|
{asString(sessionInfo.routingDecision.reason)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export interface FakeChatSocket {
|
|||||||
* missing a required field fails to compile instead of silently no-op'ing at
|
* missing a required field fails to compile instead of silently no-op'ing at
|
||||||
* runtime.
|
* runtime.
|
||||||
*/
|
*/
|
||||||
|
/** Socket.IO's built-in connection-state events. Not part of the app-level
|
||||||
|
* ServerToClientEvents contract, but real sockets always support them and
|
||||||
|
* `useChatConnection` registers a `disconnect` handler on the real socket. */
|
||||||
|
type LifecycleEvent = 'connect' | 'disconnect';
|
||||||
|
|
||||||
export function createFakeChatSocket(): {
|
export function createFakeChatSocket(): {
|
||||||
socket: FakeChatSocket;
|
socket: FakeChatSocket;
|
||||||
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
|
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
|
||||||
@@ -35,6 +40,19 @@ export function createFakeChatSocket(): {
|
|||||||
event: K,
|
event: K,
|
||||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||||
): void;
|
): void;
|
||||||
|
/** Escape hatch for malformed-payload tests: bypasses the compile-time
|
||||||
|
* payload contract to simulate a genuinely untrusted runtime value from the
|
||||||
|
* server, e.g. a `session:info` with a non-array `availableThinkingLevels`. */
|
||||||
|
serverEmitRaw(event: ServerEvent, payload: unknown): void;
|
||||||
|
/** Simulates a transient Socket.IO `disconnect` — fires any handler(s)
|
||||||
|
* registered via `socket.on('disconnect', ...)` without clearing any
|
||||||
|
* listeners, mirroring how a real reconnecting socket behaves. */
|
||||||
|
simulateDisconnect(): void;
|
||||||
|
/** Simulates socket.io-client's automatic reconnect of the *same*
|
||||||
|
* instance after a transient disconnect: marks the socket connected again
|
||||||
|
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
||||||
|
* without clearing or replacing any listeners. */
|
||||||
|
simulateReconnect(): void;
|
||||||
} {
|
} {
|
||||||
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
||||||
const emitted: EmittedEvent[] = [];
|
const emitted: EmittedEvent[] = [];
|
||||||
@@ -73,5 +91,35 @@ export function createFakeChatSocket(): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { socket, listeners, emitted, serverEmit };
|
function serverEmitRaw(event: ServerEvent, payload: unknown): void {
|
||||||
|
for (const handler of listeners.get(event) ?? []) {
|
||||||
|
(handler as (payload: unknown) => void)(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateDisconnect(): void {
|
||||||
|
socket.connected = false;
|
||||||
|
const lifecycleKey = 'disconnect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||||
|
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||||
|
(handler as () => void)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateReconnect(): void {
|
||||||
|
socket.connected = true;
|
||||||
|
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||||
|
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||||
|
(handler as () => void)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
socket,
|
||||||
|
listeners,
|
||||||
|
emitted,
|
||||||
|
serverEmit,
|
||||||
|
serverEmitRaw,
|
||||||
|
simulateDisconnect,
|
||||||
|
simulateReconnect,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ToolCallList } from './tool-call-list';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement | null;
|
||||||
|
|
||||||
|
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ToolCallList', () => {
|
||||||
|
it('renders two entries independently, without a duplicate-key warning, when a valid toolCallId is shared', async () => {
|
||||||
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<ToolCallList
|
||||||
|
tools={[
|
||||||
|
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
|
||||||
|
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = [...(container?.querySelectorAll('li') ?? [])];
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items[0]?.textContent).toContain('success');
|
||||||
|
expect(items[1]?.textContent).toContain('running');
|
||||||
|
|
||||||
|
const duplicateKeyWarning = consoleError.mock.calls.some((args) =>
|
||||||
|
args.some((arg) => typeof arg === 'string' && arg.includes('same key')),
|
||||||
|
);
|
||||||
|
expect(duplicateKeyWarning).toBe(false);
|
||||||
|
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,9 +6,17 @@ export function ToolCallList({ tools }: { tools: ToolCallState[] }): ReactElemen
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
|
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
|
||||||
{tools.map((tool) => (
|
{tools.map((tool, index) => (
|
||||||
<li key={tool.toolCallId} role={tool.status === 'error' ? 'alert' : 'status'}>
|
<li
|
||||||
{tool.toolName} — {tool.status}
|
// A valid server-controlled toolCallId can legitimately repeat
|
||||||
|
// (e.g. two tool:start events sharing one id) — keying on it alone
|
||||||
|
// would give React two identical keys. Pairing it with its
|
||||||
|
// (stable, append-only) render index keeps every key unique.
|
||||||
|
key={`${tool.toolCallId}-${index}`}
|
||||||
|
role={tool.status === 'error' || tool.status === 'anomaly' ? 'alert' : 'status'}
|
||||||
|
>
|
||||||
|
{tool.toolName} —{' '}
|
||||||
|
{tool.status === 'anomaly' ? 'unexpected end (unknown tool call)' : tool.status}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ 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 } from './test-support/fake-chat-socket';
|
||||||
|
import {
|
||||||
|
MAX_COMMAND_RESULTS,
|
||||||
|
MAX_MANIFEST_ITEMS,
|
||||||
|
MAX_MESSAGES,
|
||||||
|
MAX_STREAM_CHARS,
|
||||||
|
MAX_TOOLS,
|
||||||
|
} from './limits';
|
||||||
|
|
||||||
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
||||||
getSocketMock: vi.fn(),
|
getSocketMock: vi.fn(),
|
||||||
@@ -590,6 +597,654 @@ describe('useChatConnection', () => {
|
|||||||
expect(latest?.state.manifest?.commands.map((c) => c.name)).toEqual(['deploy']);
|
expect(latest?.state.manifest?.commands.map((c) => c.name)).toEqual(['deploy']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reconnects the same socket instance after a transient disconnect and accepts a subsequent ack/start/text stream', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.simulateDisconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listeners must still be registered — a transient disconnect must not
|
||||||
|
// tear anything down or force a fresh singleton.
|
||||||
|
expect(fake.listeners.get('message:ack')?.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.simulateReconnect();
|
||||||
|
});
|
||||||
|
expect(fake.socket.connected).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm2' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'reconnected' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.ack).toEqual({ conversationId: 'c1', messageId: 'm2' });
|
||||||
|
expect(latest?.state.streaming).toBe(true);
|
||||||
|
expect(latest?.state.text).toBe('reconnected');
|
||||||
|
expect(fake.listeners.get('message:ack')?.size).toBeGreaterThan(0);
|
||||||
|
// No new fake singleton was created — getSocket() always resolved to the
|
||||||
|
// same instance across the disconnect/reconnect cycle.
|
||||||
|
expect(getSocketMock.mock.results.every((result) => result.value === fake.socket)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets streaming and pending-send/busy state on a mid-stream disconnect', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'hi' });
|
||||||
|
});
|
||||||
|
expect(latest?.state.sending).toBe(true);
|
||||||
|
expect(latest?.state.pendingSend).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
expect(latest?.state.streaming).toBe(true);
|
||||||
|
expect(latest?.state.pendingSend).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
|
||||||
|
});
|
||||||
|
expect(latest?.state.approvalRequestPending).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.simulateDisconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.streaming).toBe(false);
|
||||||
|
expect(latest?.state.sending).toBe(false);
|
||||||
|
expect(latest?.state.pendingSend).toBe(false);
|
||||||
|
expect(latest?.state.approvalRequestPending).toBe(false);
|
||||||
|
|
||||||
|
// The send lock must also be released — a subsequent sendMessage after
|
||||||
|
// reconnect must not be permanently blocked by the interrupted turn.
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'after reconnect' });
|
||||||
|
});
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(2);
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message').at(-1)).toEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: {
|
||||||
|
conversationId: 'c1',
|
||||||
|
content: 'after reconnect',
|
||||||
|
provider: undefined,
|
||||||
|
modelId: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits and appends only one turn when sendMessage is called twice before agent:start, and cannot fork a new conversation', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'first' });
|
||||||
|
latest?.actions.sendMessage({ content: 'second' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||||
|
expect(latest?.state.messages).toHaveLength(1);
|
||||||
|
expect(latest?.state.messages[0]).toMatchObject({ role: 'user', text: 'first' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
expect(latest?.state.conversationId).toBe('c1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends a terminal anomaly entry when agent:tool:end references a toolCallId that was never started', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:tool:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 'unseen',
|
||||||
|
toolName: 'search',
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.tools).toEqual([
|
||||||
|
{ toolCallId: 'unseen', toolName: 'search', status: 'anomaly' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns unique fallback IDs to multiple malformed tool:start events, and a subsequent malformed tool:end always appends a new anomaly without mutating either prior entry', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('agent:tool:start', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: { bad: 'object' },
|
||||||
|
toolName: 'search',
|
||||||
|
});
|
||||||
|
fake.serverEmitRaw('agent:tool:start', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: '',
|
||||||
|
toolName: 'shell',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.tools).toHaveLength(2);
|
||||||
|
const [first, second] = latest?.state.tools ?? [];
|
||||||
|
expect(first?.status).toBe('running');
|
||||||
|
expect(second?.status).toBe('running');
|
||||||
|
expect(first?.toolCallId).not.toBe(second?.toolCallId);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('agent:tool:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: null,
|
||||||
|
toolName: 'unknown',
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.tools).toHaveLength(3);
|
||||||
|
// Neither malformed-start entry was mutated by the malformed end.
|
||||||
|
expect(latest?.state.tools[0]).toEqual(first);
|
||||||
|
expect(latest?.state.tools[1]).toEqual(second);
|
||||||
|
// The malformed end always appends its own terminal anomaly rather than
|
||||||
|
// colliding with (and silently flipping) an earlier fallback ID.
|
||||||
|
const terminal = latest?.state.tools[2];
|
||||||
|
expect(terminal?.status).toBe('anomaly');
|
||||||
|
expect(terminal?.toolCallId).not.toBe(first?.toolCallId);
|
||||||
|
expect(terminal?.toolCallId).not.toBe(second?.toolCallId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates only the first matching tool when a tool:end references a toolCallId shared by two tool:start entries', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmit('agent:tool:start', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 'dup',
|
||||||
|
toolName: 'search',
|
||||||
|
});
|
||||||
|
fake.serverEmit('agent:tool:start', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 'dup',
|
||||||
|
toolName: 'search',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:tool:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 'dup',
|
||||||
|
toolName: 'search',
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.tools).toEqual([
|
||||||
|
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
|
||||||
|
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the tools list at MAX_TOOLS when flooded with tool:start events', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
for (let i = 0; i < MAX_TOOLS + 10; i += 1) {
|
||||||
|
fake.serverEmit('agent:tool:start', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: `t${i}`,
|
||||||
|
toolName: 'search',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.tools).toHaveLength(MAX_TOOLS);
|
||||||
|
// Oldest dropped deterministically — the most recent tool call survives.
|
||||||
|
expect(latest?.state.tools.at(-1)?.toolCallId).toBe(`t${MAX_TOOLS + 9}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a stale agent:end for a foreign conversation cannot re-arm the send lock for a same-tick in-flight second turn', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'second turn' });
|
||||||
|
});
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||||
|
|
||||||
|
// While the second turn's send is in flight (sent but agent:start not yet
|
||||||
|
// received), a terminal event for an unrelated conversation arrives in the
|
||||||
|
// same tick as a follow-up send attempt — it must not re-arm the lock.
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:end', { conversationId: 'other' });
|
||||||
|
latest?.actions.sendMessage({ content: 'third turn attempt' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||||
|
expect(latest?.state.messages.some((m) => m.text === 'third turn attempt')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a stale error for a foreign conversation cannot re-arm the send lock for a same-tick in-flight second turn', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'second turn' });
|
||||||
|
});
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('error', { conversationId: 'other', error: 'unrelated failure' });
|
||||||
|
latest?.actions.sendMessage({ content: 'third turn attempt' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||||
|
expect(latest?.state.messages.some((m) => m.text === 'third turn attempt')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw and normalizes to empty lists when commands:manifest arrives with null commands/skills', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('commands:manifest', {
|
||||||
|
manifest: { commands: null, skills: null, version: 1 },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.manifest?.commands).toEqual([]);
|
||||||
|
expect(latest?.state.manifest?.skills).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw and normalizes to empty lists when system:reload arrives with non-array commands/skills', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('system:reload', {
|
||||||
|
commands: 'not-an-array',
|
||||||
|
skills: undefined,
|
||||||
|
providers: ['anthropic'],
|
||||||
|
message: 'Commands reloaded',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.manifest?.commands).toEqual([]);
|
||||||
|
expect(latest?.state.manifest?.skills).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a safe tool label when agent:tool:start carries a non-string toolName', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('agent:tool:start', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 't1',
|
||||||
|
toolName: { evil: 'object' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.tools).toEqual([
|
||||||
|
{ toolCallId: 't1', toolName: 'Unknown tool', status: 'running' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a session:info event whose entire payload is null, without throwing', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('session:info', null);
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.sessionInfo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits only one command:approve when approveCommand is called twice in the same tick, preserving the first frozen args', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
|
||||||
|
latest?.actions.approveCommand({ command: 'deploy', args: 'staging' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
|
||||||
|
expect(latest?.state.pendingApproval).toEqual({ command: 'deploy', args: 'prod' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps streamed agent:text at MAX_STREAM_CHARS, keeping only the most recent characters', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'a'.repeat(MAX_STREAM_CHARS) });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'b'.repeat(10) });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.text).toHaveLength(MAX_STREAM_CHARS);
|
||||||
|
expect(latest?.state.text.endsWith('b'.repeat(10))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps commandResults at MAX_COMMAND_RESULTS when flooded with command:result events', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
for (let i = 0; i < MAX_COMMAND_RESULTS + 5; i += 1) {
|
||||||
|
fake.serverEmit('command:result', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: `cmd${i}`,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.commandResults).toHaveLength(MAX_COMMAND_RESULTS);
|
||||||
|
expect(latest?.state.commandResults.at(-1)?.command).toBe(`cmd${MAX_COMMAND_RESULTS + 4}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps commands:manifest commands/skills at MAX_MANIFEST_ITEMS', async () => {
|
||||||
|
const commands = Array.from({ length: MAX_MANIFEST_ITEMS + 5 }, (_, i) => ({
|
||||||
|
name: `cmd${i}`,
|
||||||
|
aliases: [],
|
||||||
|
description: '',
|
||||||
|
scope: 'core' as const,
|
||||||
|
execution: 'socket' as const,
|
||||||
|
available: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('commands:manifest', { manifest: { commands, skills: [], version: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.manifest?.commands).toHaveLength(MAX_MANIFEST_ITEMS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps a system:reload manifest replacement at MAX_MANIFEST_ITEMS', async () => {
|
||||||
|
const commands = Array.from({ length: MAX_MANIFEST_ITEMS + 5 }, (_, i) => ({
|
||||||
|
name: `cmd${i}`,
|
||||||
|
aliases: [],
|
||||||
|
description: '',
|
||||||
|
scope: 'core' as const,
|
||||||
|
execution: 'socket' as const,
|
||||||
|
available: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('system:reload', {
|
||||||
|
commands,
|
||||||
|
skills: [],
|
||||||
|
providers: ['anthropic'],
|
||||||
|
message: 'reloaded',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.manifest?.commands).toHaveLength(MAX_MANIFEST_ITEMS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a malformed (object) conversationId on the establishing message:ack, leaving the turn recoverable for a later valid ack', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'hi' });
|
||||||
|
});
|
||||||
|
expect(latest?.state.pendingSend).toBe(true);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('message:ack', { conversationId: { bad: 'object' }, messageId: 'm0' });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.conversationId).toBeNull();
|
||||||
|
expect(latest?.state.pendingSend).toBe(true);
|
||||||
|
expect(latest?.state.ack).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.conversationId).toBe('c1');
|
||||||
|
expect(latest?.state.ack).toEqual({ conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an empty-string and a null conversationId on the establishing message:ack', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('message:ack', { conversationId: '', messageId: 'm1' });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
expect(latest?.state.conversationId).toBeNull();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('message:ack', { conversationId: null, messageId: 'm1' });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
expect(latest?.state.conversationId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes a malformed (non-string) ack messageId to a visible "unknown" fallback instead of storing the raw value', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.ack).toEqual({ conversationId: 'c1', messageId: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a malformed-conversationId error as a terminal startup failure for a brand-new pending send, releasing the send lock for a retry', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'hi' });
|
||||||
|
});
|
||||||
|
expect(latest?.state.pendingSend).toBe(true);
|
||||||
|
expect(latest?.state.sending).toBe(true);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('error', { conversationId: { bad: 'object' }, error: 'boom' });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.conversationId).toBeNull();
|
||||||
|
expect(latest?.state.pendingSend).toBe(false);
|
||||||
|
expect(latest?.state.sending).toBe(false);
|
||||||
|
expect(latest?.state.streaming).toBe(false);
|
||||||
|
expect(latest?.state.approvalRequestPending).toBe(false);
|
||||||
|
expect(latest?.state.error).toBe('Unable to start this conversation. Please try again.');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'retry' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(2);
|
||||||
|
expect(latest?.state.messages.some((m) => m.text === 'retry')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a malformed-conversationId agent:end as a terminal startup failure for a brand-new pending send, releasing the send lock for a retry', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'hi' });
|
||||||
|
});
|
||||||
|
expect(latest?.state.pendingSend).toBe(true);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('agent:end', { conversationId: '' });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.conversationId).toBeNull();
|
||||||
|
expect(latest?.state.pendingSend).toBe(false);
|
||||||
|
expect(latest?.state.sending).toBe(false);
|
||||||
|
expect(latest?.state.streaming).toBe(false);
|
||||||
|
expect(latest?.state.approvalRequestPending).toBe(false);
|
||||||
|
expect(latest?.state.error).toBe('Unable to start this conversation. Please try again.');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'retry again' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(2);
|
||||||
|
expect(latest?.state.messages.some((m) => m.text === 'retry again')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a foreign valid-conversationId error after a conversation is already active remains ignored and cannot unlock anything', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
expect(latest?.state.streaming).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('error', { conversationId: 'other', error: 'unrelated' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.conversationId).toBe('c1');
|
||||||
|
expect(latest?.state.streaming).toBe(true);
|
||||||
|
expect(latest?.state.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a malformed (null) conversationId on an establishing agent:start via resolveScopedConversation, leaving the turn recoverable for a later valid one', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'hi' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('agent:start', { conversationId: null });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.conversationId).toBeNull();
|
||||||
|
expect(latest?.state.pendingSend).toBe(true);
|
||||||
|
expect(latest?.state.streaming).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.conversationId).toBe('c1');
|
||||||
|
expect(latest?.state.streaming).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a command:approval with a truthy but non-literal-true success into a denial, releasing the pending lock without enabling execution', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('command:approval', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: { truthy: 'object' },
|
||||||
|
approvalId: 'ap1',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.approval?.success).toBe(false);
|
||||||
|
expect(latest?.state.approvalRequestPending).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.runApprovedCommand();
|
||||||
|
});
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a command:approval with success: true but a malformed (object) approvalId into a denial that cannot be run', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('command:approval', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: { bad: 'object' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.approval?.success).toBe(false);
|
||||||
|
expect(latest?.state.approval?.approvalId).toBeUndefined();
|
||||||
|
expect(latest?.state.approvalRequestPending).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.runApprovedCommand();
|
||||||
|
});
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes command:result.success to a literal boolean, never displaying a truthy non-boolean value as success', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('command:result', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'model',
|
||||||
|
success: { truthy: 'object' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(latest?.state.commandResults.at(-1)?.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps messages at MAX_MESSAGES when flooded with agent:start/text/end cycles on an established conversation', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
for (let i = 0; i < MAX_MESSAGES + 5; i += 1) {
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: `reply ${i}` });
|
||||||
|
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.messages).toHaveLength(MAX_MESSAGES);
|
||||||
|
expect(latest?.state.messages.at(-1)?.text).toBe(`reply ${MAX_MESSAGES + 4}`);
|
||||||
|
// Every retained message.id must stay unique across the cap boundary — a
|
||||||
|
// length-derived id would collide once the array plateaus at MAX_MESSAGES.
|
||||||
|
const ids = latest?.state.messages.map((m) => m.id) ?? [];
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
it('removes every listener and tears down the socket on cleanup, using no network', async () => {
|
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);
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import { useEffect, useReducer, useRef } from 'react';
|
import { useEffect, useReducer, useRef } from 'react';
|
||||||
import { destroySocket, getSocket } from '@/lib/socket';
|
import { destroySocket, getSocket } from '@/lib/socket';
|
||||||
|
import {
|
||||||
|
MAX_COMMAND_RESULTS,
|
||||||
|
MAX_EXECUTED_APPROVAL_IDS,
|
||||||
|
MAX_MANIFEST_ITEMS,
|
||||||
|
MAX_MESSAGES,
|
||||||
|
MAX_STREAM_CHARS,
|
||||||
|
MAX_TOOLS,
|
||||||
|
} from './limits';
|
||||||
|
import { asConversationId, asFiniteNumber, asString, isRecord } from './runtime-guards';
|
||||||
import type {
|
import type {
|
||||||
AgentEndPayload,
|
AgentEndPayload,
|
||||||
AgentStartPayload,
|
AgentStartPayload,
|
||||||
AgentTextPayload,
|
AgentTextPayload,
|
||||||
AgentThinkingPayload,
|
AgentThinkingPayload,
|
||||||
|
CommandDef,
|
||||||
CommandManifest,
|
CommandManifest,
|
||||||
CommandManifestPayload,
|
CommandManifestPayload,
|
||||||
ErrorPayload,
|
ErrorPayload,
|
||||||
MessageAckPayload,
|
MessageAckPayload,
|
||||||
SessionInfoPayload,
|
SessionInfoPayload,
|
||||||
SessionUsagePayload,
|
SessionUsagePayload,
|
||||||
|
SkillCommandDef,
|
||||||
SlashCommandApprovalResultPayload,
|
SlashCommandApprovalResultPayload,
|
||||||
SlashCommandResultPayload,
|
SlashCommandResultPayload,
|
||||||
SystemReloadPayload,
|
SystemReloadPayload,
|
||||||
@@ -21,7 +32,113 @@ import type {
|
|||||||
export interface ToolCallState {
|
export interface ToolCallState {
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
status: 'running' | 'success' | 'error';
|
status: 'running' | 'success' | 'error' | 'anomaly';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends `addition` to `existing`, keeping at most `max` characters by
|
||||||
|
* dropping the oldest (leading) characters once the cap is exceeded. */
|
||||||
|
function capAppendString(existing: string, addition: string, max: number): string {
|
||||||
|
const next = existing + addition;
|
||||||
|
return next.length > max ? next.slice(next.length - max) : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pushes `item` onto `arr`, dropping the oldest entries once `max` is exceeded. */
|
||||||
|
function capPush<T>(arr: T[], item: T, max: number): T[] {
|
||||||
|
const next = [...arr, item];
|
||||||
|
return next.length > max ? next.slice(next.length - max) : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A valid `toolCallId` from the wire is a non-empty string — a malformed or
|
||||||
|
* absent one (non-string, or empty string) must never be treated as if it
|
||||||
|
* named a real tool call. */
|
||||||
|
function isValidToolCallId(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && value.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown when a scoped `error`/`agent:end` cannot be attributed to any
|
||||||
|
* conversation (a malformed/missing conversationId) while a send is still
|
||||||
|
* pending and no conversation has ever been established. There is no valid
|
||||||
|
* identity left to recover the turn under, so it is surfaced as a terminal
|
||||||
|
* startup failure instead of leaving `sending`/`pendingSend` stuck forever. */
|
||||||
|
const CONVERSATION_START_FAILURE = 'Unable to start this conversation. Please try again.';
|
||||||
|
|
||||||
|
/** True only for the narrow case a malformed-conversationId `error`/`agent:end`
|
||||||
|
* must be treated as a terminal startup failure: no conversation has ever been
|
||||||
|
* established yet, and a send is still pending one. Once a conversation is
|
||||||
|
* active, or when no send is pending, an unattributable event stays a no-op —
|
||||||
|
* dropping it (rather than guessing which turn it belongs to) is what lets a
|
||||||
|
* later genuinely valid event still recover the turn. */
|
||||||
|
function isUnrecoverableStartupFailure(state: ChatConnectionState): boolean {
|
||||||
|
return state.conversationId === null && state.pendingSend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic, unique-per-event fallback id for a malformed `toolCallId`.
|
||||||
|
* Sourced from a monotonically increasing counter carried in state (`toolSeq`)
|
||||||
|
* rather than `tools.length`, so it stays collision-free even once `tools` is
|
||||||
|
* capped at MAX_TOOLS and its length stops changing. */
|
||||||
|
function nextToolFallbackId(toolSeq: number): string {
|
||||||
|
return `unknown-tool-call-${toolSeq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncates a server-provided collection to at most `max` entries. `arr` is
|
||||||
|
* `unknown` because it comes straight from a raw socket payload — a
|
||||||
|
* malformed/compromised gateway can send `null` or any non-array value here,
|
||||||
|
* which must normalize to an empty list rather than throw. */
|
||||||
|
function capList<T>(arr: unknown, max: number): T[] {
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
return (arr.length > max ? arr.slice(0, max) : arr) as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes a raw message:ack payload before it is stored — `messageId` is
|
||||||
|
* given a stable, visible "unknown" fallback rather than storing a raw
|
||||||
|
* non-string value (which would either render as "[object Object]" or,
|
||||||
|
* with an unguarded render site, throw). */
|
||||||
|
function sanitizeAck(payload: MessageAckPayload, conversationId: string): MessageAckPayload {
|
||||||
|
return { conversationId, messageId: asString(payload.messageId, 'unknown') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes a raw command:approval payload before it is stored. A command
|
||||||
|
* string matching `pendingApproval.command` alone is not sufficient proof of
|
||||||
|
* a genuine, executable approval: `success` must be the literal boolean
|
||||||
|
* `true`, and a successful approval requires a non-empty string
|
||||||
|
* `approvalId`. Anything else is normalized to a denial (`success: false`,
|
||||||
|
* no `approvalId`) so the pending request resolves and the approve lock is
|
||||||
|
* released instead of leaving the UI hung on a response it can never trust
|
||||||
|
* enough to enable "Run approved command" for. `expiresAt`/`message` are
|
||||||
|
* retained only when they are strings (message is never rendered as-is). */
|
||||||
|
function sanitizeApproval(
|
||||||
|
payload: SlashCommandApprovalResultPayload,
|
||||||
|
conversationId: string,
|
||||||
|
command: string,
|
||||||
|
): SlashCommandApprovalResultPayload {
|
||||||
|
const approvalId =
|
||||||
|
typeof payload.approvalId === 'string' && payload.approvalId.length > 0
|
||||||
|
? payload.approvalId
|
||||||
|
: undefined;
|
||||||
|
const success = payload.success === true && approvalId !== undefined;
|
||||||
|
return {
|
||||||
|
conversationId,
|
||||||
|
command,
|
||||||
|
success,
|
||||||
|
approvalId: success ? approvalId : undefined,
|
||||||
|
expiresAt: typeof payload.expiresAt === 'string' ? payload.expiresAt : undefined,
|
||||||
|
message: typeof payload.message === 'string' ? payload.message : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes a raw command:result payload before it is stored — `success`
|
||||||
|
* is coerced to a literal boolean so a truthy non-boolean value (e.g. an
|
||||||
|
* object) can never be displayed as a successful result. */
|
||||||
|
function sanitizeCommandResult(
|
||||||
|
payload: SlashCommandResultPayload,
|
||||||
|
conversationId: string,
|
||||||
|
): SlashCommandResultPayload {
|
||||||
|
return {
|
||||||
|
conversationId,
|
||||||
|
command: asString(payload.command, 'unknown'),
|
||||||
|
success: payload.success === true,
|
||||||
|
message: typeof payload.message === 'string' ? payload.message : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatTranscriptMessage {
|
export interface ChatTranscriptMessage {
|
||||||
@@ -42,6 +159,10 @@ export interface ChatConnectionState {
|
|||||||
* first scoped server event naming a conversation may establish it (the gateway
|
* first scoped server event naming a conversation may establish it (the gateway
|
||||||
* does not guarantee message:ack is the first event for a new conversation). */
|
* does not guarantee message:ack is the first event for a new conversation). */
|
||||||
pendingSend: boolean;
|
pendingSend: boolean;
|
||||||
|
/** True from the moment a message is sent until its turn ends (agent:end),
|
||||||
|
* errors, or the connection drops — guards against a second turn starting
|
||||||
|
* while one is already in flight. */
|
||||||
|
sending: boolean;
|
||||||
ack: MessageAckPayload | null;
|
ack: MessageAckPayload | null;
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -58,6 +179,16 @@ export interface ChatConnectionState {
|
|||||||
systemReload: SystemReloadPayload | null;
|
systemReload: SystemReloadPayload | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
messages: ChatTranscriptMessage[];
|
messages: ChatTranscriptMessage[];
|
||||||
|
/** Monotonically increasing counter used to mint transcript message ids —
|
||||||
|
* never reset while retained messages remain, so ids stay unique across the
|
||||||
|
* MAX_MESSAGES cap boundary (unlike a `messages.length`-derived id, which
|
||||||
|
* plateaus once the array is capped). */
|
||||||
|
messageSeq: number;
|
||||||
|
/** Monotonically increasing counter used to mint fallback tool-call ids for
|
||||||
|
* malformed (non-string/empty) `toolCallId`s — never reset while retained
|
||||||
|
* tools remain, so fallback ids stay unique across the MAX_TOOLS cap
|
||||||
|
* boundary. */
|
||||||
|
toolSeq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatConnectionActions {
|
export interface ChatConnectionActions {
|
||||||
@@ -77,6 +208,7 @@ export interface ChatConnectionValue {
|
|||||||
const initialState: ChatConnectionState = {
|
const initialState: ChatConnectionState = {
|
||||||
conversationId: null,
|
conversationId: null,
|
||||||
pendingSend: false,
|
pendingSend: false,
|
||||||
|
sending: false,
|
||||||
ack: null,
|
ack: null,
|
||||||
streaming: false,
|
streaming: false,
|
||||||
text: '',
|
text: '',
|
||||||
@@ -92,6 +224,8 @@ const initialState: ChatConnectionState = {
|
|||||||
systemReload: null,
|
systemReload: null,
|
||||||
error: null,
|
error: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
messageSeq: 0,
|
||||||
|
toolSeq: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
@@ -110,7 +244,8 @@ type Action =
|
|||||||
| { type: 'server/error'; payload: ErrorPayload }
|
| { type: 'server/error'; payload: ErrorPayload }
|
||||||
| { type: 'local/send'; content: string }
|
| { type: 'local/send'; content: string }
|
||||||
| { type: 'local/approve-request'; command: string; args?: string }
|
| { type: 'local/approve-request'; command: string; args?: string }
|
||||||
| { type: 'local/consume-approval' };
|
| { type: 'local/consume-approval' }
|
||||||
|
| { type: 'local/disconnect' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves whether a scoped server event (one carrying a conversationId) belongs to
|
* Resolves whether a scoped server event (one carrying a conversationId) belongs to
|
||||||
@@ -119,11 +254,22 @@ type Action =
|
|||||||
* failure, can both arrive first) — so while a message is pending and no conversation
|
* failure, can both arrive first) — so while a message is pending and no conversation
|
||||||
* is active yet, the first scoped event names the conversation instead of being
|
* is active yet, the first scoped event names the conversation instead of being
|
||||||
* dropped. Once a conversation is active, only its own events pass.
|
* dropped. Once a conversation is active, only its own events pass.
|
||||||
|
*
|
||||||
|
* `rawConversationId` is `unknown`, not `string` — every payload is runtime-untrusted
|
||||||
|
* regardless of its compile-time contract, and this is the single place that may
|
||||||
|
* establish `state.conversationId`. A malformed value (non-string/empty) is rejected
|
||||||
|
* via the central `asConversationId` guard and never adopted: the event is treated as
|
||||||
|
* inactive/dropped, leaving state (including `pendingSend`) untouched so a later
|
||||||
|
* genuinely valid event can still establish or recover the turn.
|
||||||
*/
|
*/
|
||||||
function resolveScopedConversation(
|
function resolveScopedConversation(
|
||||||
state: ChatConnectionState,
|
state: ChatConnectionState,
|
||||||
conversationId: string,
|
rawConversationId: unknown,
|
||||||
): { active: true; state: ChatConnectionState } | { active: false; state: null } {
|
): { active: true; state: ChatConnectionState } | { active: false; state: null } {
|
||||||
|
const conversationId = asConversationId(rawConversationId);
|
||||||
|
if (conversationId === null) {
|
||||||
|
return { active: false, state: null };
|
||||||
|
}
|
||||||
if (state.conversationId === conversationId) {
|
if (state.conversationId === conversationId) {
|
||||||
return { active: true, state };
|
return { active: true, state };
|
||||||
}
|
}
|
||||||
@@ -133,20 +279,42 @@ function resolveScopedConversation(
|
|||||||
return { active: false, state: null };
|
return { active: false, state: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServerAction = Extract<Action, { type: `server/${string}` }>;
|
||||||
|
|
||||||
|
function isServerAction(action: Action): action is ServerAction {
|
||||||
|
return action.type.startsWith('server/');
|
||||||
|
}
|
||||||
|
|
||||||
function reduce(state: ChatConnectionState, action: Action): ChatConnectionState {
|
function reduce(state: ChatConnectionState, action: Action): ChatConnectionState {
|
||||||
|
// A malformed packet (the whole payload is null/undefined/a primitive, not
|
||||||
|
// an object) is ignored outright rather than crashing the reducer on the
|
||||||
|
// first field dereference in the case below — every server/* action shape
|
||||||
|
// declares a `payload` field, so this guard covers all of them uniformly.
|
||||||
|
if (isServerAction(action) && !isRecord(action.payload)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'server/message:ack': {
|
case 'server/message:ack': {
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
if (state.conversationId === null) {
|
if (state.conversationId === null) {
|
||||||
|
const conversationId = asConversationId(payload.conversationId);
|
||||||
|
// A malformed first frame (non-string/empty conversationId) is ignored
|
||||||
|
// outright rather than adopted — adopting it would permanently
|
||||||
|
// desynchronize every later scoped event's strict-equality check
|
||||||
|
// against a value that can never again match. Leaving state untouched
|
||||||
|
// (conversationId stays null) lets a later genuinely valid ack/start
|
||||||
|
// still establish the turn.
|
||||||
|
if (conversationId === null) return state;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
conversationId: payload.conversationId,
|
conversationId,
|
||||||
pendingSend: false,
|
pendingSend: false,
|
||||||
ack: payload,
|
ack: sanitizeAck(payload, conversationId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (payload.conversationId !== state.conversationId) return state;
|
if (payload.conversationId !== state.conversationId) return state;
|
||||||
return { ...state, ack: payload };
|
return { ...state, ack: sanitizeAck(payload, state.conversationId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'server/agent:start': {
|
case 'server/agent:start': {
|
||||||
@@ -158,65 +326,124 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
|||||||
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 { ...resolved.state, text: resolved.state.text + action.payload.text };
|
return {
|
||||||
|
...resolved.state,
|
||||||
|
text: capAppendString(resolved.state.text, asString(action.payload.text), MAX_STREAM_CHARS),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { ...resolved.state, thinking: resolved.state.thinking + action.payload.text };
|
return {
|
||||||
|
...resolved.state,
|
||||||
|
thinking: capAppendString(
|
||||||
|
resolved.state.thinking,
|
||||||
|
asString(action.payload.text),
|
||||||
|
MAX_STREAM_CHARS,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'server/agent:tool:start': {
|
case 'server/agent:tool:start': {
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
const resolved = resolveScopedConversation(state, payload.conversationId);
|
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||||
if (!resolved.active) return state;
|
if (!resolved.active) return state;
|
||||||
|
const valid = isValidToolCallId(payload.toolCallId);
|
||||||
|
const toolSeq = resolved.state.toolSeq;
|
||||||
const tool: ToolCallState = {
|
const tool: ToolCallState = {
|
||||||
toolCallId: payload.toolCallId,
|
// Each malformed toolCallId gets its own fresh fallback id (never the
|
||||||
toolName: payload.toolName,
|
// fixed 'unknown-tool-call' constant) so distinct malformed starts
|
||||||
|
// never collide with each other.
|
||||||
|
toolCallId: valid ? payload.toolCallId : nextToolFallbackId(toolSeq),
|
||||||
|
toolName: asString(payload.toolName, 'Unknown tool'),
|
||||||
status: 'running',
|
status: 'running',
|
||||||
};
|
};
|
||||||
return { ...resolved.state, tools: [...resolved.state.tools, tool] };
|
return {
|
||||||
|
...resolved.state,
|
||||||
|
tools: capPush(resolved.state.tools, tool, MAX_TOOLS),
|
||||||
|
toolSeq: valid ? toolSeq : toolSeq + 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'server/agent:tool:end': {
|
case 'server/agent:tool:end': {
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
const resolved = resolveScopedConversation(state, payload.conversationId);
|
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||||
if (!resolved.active) return state;
|
if (!resolved.active) return state;
|
||||||
|
const valid = isValidToolCallId(payload.toolCallId);
|
||||||
|
const toolSeq = resolved.state.toolSeq;
|
||||||
|
const toolCallId = valid ? payload.toolCallId : nextToolFallbackId(toolSeq);
|
||||||
|
const toolName = asString(payload.toolName, 'Unknown tool');
|
||||||
|
const nextToolSeq = valid ? toolSeq : toolSeq + 1;
|
||||||
|
// A fresh fallback id (malformed toolCallId) can never match an
|
||||||
|
// existing entry, so this only ever finds a genuine prior tool:start.
|
||||||
|
const matchIndex = resolved.state.tools.findIndex((tool) => tool.toolCallId === toolCallId);
|
||||||
|
if (matchIndex === -1) {
|
||||||
|
// A tool:end for an ID we never saw a tool:start for is a protocol
|
||||||
|
// anomaly, not a no-op — surface it visibly instead of silently
|
||||||
|
// dropping it.
|
||||||
|
const anomaly: ToolCallState = { toolCallId, toolName, status: 'anomaly' };
|
||||||
|
return {
|
||||||
|
...resolved.state,
|
||||||
|
tools: capPush(resolved.state.tools, anomaly, MAX_TOOLS),
|
||||||
|
toolSeq: nextToolSeq,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Update at most the first matching entry — if `toolCallId` is
|
||||||
|
// (unexpectedly) shared by more than one retained tool, updating every
|
||||||
|
// match would cross-contaminate unrelated statuses (and could silently
|
||||||
|
// overwrite a distinct entry's 'anomaly' status).
|
||||||
return {
|
return {
|
||||||
...resolved.state,
|
...resolved.state,
|
||||||
tools: resolved.state.tools.map((tool) =>
|
tools: resolved.state.tools.map((tool, index) =>
|
||||||
tool.toolCallId === payload.toolCallId
|
index === matchIndex ? { ...tool, status: payload.isError ? 'error' : 'success' } : tool,
|
||||||
? { ...tool, status: payload.isError ? 'error' : 'success' }
|
|
||||||
: tool,
|
|
||||||
),
|
),
|
||||||
|
toolSeq: nextToolSeq,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'server/agent:end': {
|
case 'server/agent:end': {
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
const resolved = resolveScopedConversation(state, payload.conversationId);
|
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||||
if (!resolved.active) return state;
|
if (!resolved.active) {
|
||||||
|
if (isUnrecoverableStartupFailure(state)) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
pendingSend: false,
|
||||||
|
sending: false,
|
||||||
|
streaming: false,
|
||||||
|
approvalRequestPending: false,
|
||||||
|
error: CONVERSATION_START_FAILURE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
const next = resolved.state;
|
const next = resolved.state;
|
||||||
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(
|
||||||
...next.messages,
|
next.messages,
|
||||||
{
|
{
|
||||||
id: `assistant-${payload.conversationId}-${next.messages.length}`,
|
// Sourced from the reducer-owned `messageSeq` counter, not
|
||||||
|
// `messages.length` — the latter plateaus once MAX_MESSAGES is
|
||||||
|
// reached under a flood, producing duplicate ids/React keys.
|
||||||
|
id: `assistant-${payload.conversationId}-${next.messageSeq}`,
|
||||||
role: 'assistant' as const,
|
role: 'assistant' as const,
|
||||||
text: next.text,
|
text: next.text,
|
||||||
thinking: next.thinking || undefined,
|
thinking: next.thinking || undefined,
|
||||||
},
|
},
|
||||||
]
|
MAX_MESSAGES,
|
||||||
|
)
|
||||||
: next.messages;
|
: next.messages;
|
||||||
return {
|
return {
|
||||||
...next,
|
...next,
|
||||||
streaming: false,
|
streaming: false,
|
||||||
|
sending: false,
|
||||||
text: '',
|
text: '',
|
||||||
thinking: '',
|
thinking: '',
|
||||||
usage: payload.usage ?? next.usage,
|
usage: payload.usage ?? next.usage,
|
||||||
messages,
|
messages,
|
||||||
|
messageSeq: hasContent ? next.messageSeq + 1 : next.messageSeq,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,14 +454,37 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'server/commands:manifest': {
|
case 'server/commands:manifest': {
|
||||||
return { ...state, manifest: action.payload.manifest };
|
// `manifest` itself, and its `commands`/`skills` fields, are raw
|
||||||
|
// socket payload values — any of them can be null/non-object at
|
||||||
|
// runtime regardless of the compile-time contract.
|
||||||
|
const manifest: Record<string, unknown> = isRecord(action.payload.manifest)
|
||||||
|
? action.payload.manifest
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
manifest: {
|
||||||
|
commands: capList<CommandDef>(manifest.commands, MAX_MANIFEST_ITEMS),
|
||||||
|
skills: capList<SkillCommandDef>(manifest.skills, MAX_MANIFEST_ITEMS),
|
||||||
|
version: asFiniteNumber(manifest.version, state.manifest?.version ?? 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'server/command:result': {
|
case 'server/command:result': {
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
const resolved = resolveScopedConversation(state, payload.conversationId);
|
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||||
if (!resolved.active) return state;
|
if (!resolved.active) return state;
|
||||||
return { ...resolved.state, commandResults: [...resolved.state.commandResults, payload] };
|
const next = resolved.state;
|
||||||
|
const conversationId = next.conversationId;
|
||||||
|
if (conversationId === null) return next;
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
commandResults: capPush(
|
||||||
|
next.commandResults,
|
||||||
|
sanitizeCommandResult(payload, conversationId),
|
||||||
|
MAX_COMMAND_RESULTS,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'server/command:approval': {
|
case 'server/command:approval': {
|
||||||
@@ -242,21 +492,32 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
|||||||
const resolved = resolveScopedConversation(state, payload.conversationId);
|
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||||
if (!resolved.active) return state;
|
if (!resolved.active) return state;
|
||||||
const next = resolved.state;
|
const next = resolved.state;
|
||||||
if (!next.pendingApproval || next.pendingApproval.command !== payload.command) {
|
const conversationId = next.conversationId;
|
||||||
|
if (
|
||||||
|
conversationId === null ||
|
||||||
|
!next.pendingApproval ||
|
||||||
|
typeof payload.command !== 'string' ||
|
||||||
|
payload.command !== next.pendingApproval.command
|
||||||
|
) {
|
||||||
// Stale or mismatched response for a request that is no longer (or never was)
|
// Stale or mismatched response for a request that is no longer (or never was)
|
||||||
// the one outstanding approval — do not let it replace active approval state.
|
// the one outstanding approval — do not let it replace active approval state.
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
return { ...next, approval: payload, approvalRequestPending: false };
|
return {
|
||||||
|
...next,
|
||||||
|
approval: sanitizeApproval(payload, conversationId, payload.command),
|
||||||
|
approvalRequestPending: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'server/system:reload': {
|
case 'server/system:reload': {
|
||||||
|
const { payload } = action;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
systemReload: action.payload,
|
systemReload: { ...payload, message: asString(payload.message, 'Commands reloaded.') },
|
||||||
manifest: {
|
manifest: {
|
||||||
commands: action.payload.commands,
|
commands: capList<CommandDef>(payload.commands, MAX_MANIFEST_ITEMS),
|
||||||
skills: action.payload.skills,
|
skills: capList<SkillCommandDef>(payload.skills, MAX_MANIFEST_ITEMS),
|
||||||
version: state.manifest?.version ?? 0,
|
version: state.manifest?.version ?? 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -265,24 +526,62 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
|||||||
case 'server/error': {
|
case 'server/error': {
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
const resolved = resolveScopedConversation(state, payload.conversationId);
|
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||||
if (!resolved.active) return state;
|
if (!resolved.active) {
|
||||||
return { ...resolved.state, error: payload.error, streaming: false };
|
if (isUnrecoverableStartupFailure(state)) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
pendingSend: false,
|
||||||
|
sending: false,
|
||||||
|
streaming: false,
|
||||||
|
approvalRequestPending: false,
|
||||||
|
error: CONVERSATION_START_FAILURE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...resolved.state,
|
||||||
|
error: asString(payload.error, 'An error occurred.'),
|
||||||
|
streaming: false,
|
||||||
|
sending: false,
|
||||||
|
// A turn-scoped error also invalidates any approval request awaiting
|
||||||
|
// a response — the gateway that just errored is unlikely to still
|
||||||
|
// answer it, and the synchronous approveLockRef mirrors this field.
|
||||||
|
approvalRequestPending: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'local/send': {
|
case 'local/send': {
|
||||||
const message: ChatTranscriptMessage = {
|
const message: ChatTranscriptMessage = {
|
||||||
id: `user-${Date.now()}-${state.messages.length}`,
|
// Sourced from the reducer-owned `messageSeq` counter — see the
|
||||||
|
// `server/agent:end` case for why `messages.length`/`Date.now()` are
|
||||||
|
// not collision-safe once MAX_MESSAGES is reached under a flood.
|
||||||
|
id: `user-${state.messageSeq}`,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
text: action.content,
|
text: action.content,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
messages: [...state.messages, message],
|
messages: capPush(state.messages, message, MAX_MESSAGES),
|
||||||
|
messageSeq: state.messageSeq + 1,
|
||||||
error: null,
|
error: null,
|
||||||
|
sending: true,
|
||||||
pendingSend: state.conversationId === null ? true : state.pendingSend,
|
pendingSend: state.conversationId === null ? true : state.pendingSend,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'local/disconnect': {
|
||||||
|
// A transient socket disconnect must not leave the UI stuck waiting on
|
||||||
|
// a turn/approval/send that will never resolve on this connection.
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
streaming: false,
|
||||||
|
sending: false,
|
||||||
|
pendingSend: false,
|
||||||
|
approvalRequestPending: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'local/approve-request': {
|
case 'local/approve-request': {
|
||||||
// Only one approval request may be outstanding at a time; a second request
|
// Only one approval request may be outstanding at a time; a second request
|
||||||
// before the first resolves is ignored so it cannot overwrite the original
|
// before the first resolves is ignored so it cannot overwrite the original
|
||||||
@@ -315,6 +614,32 @@ export function useChatConnection(): ChatConnectionValue {
|
|||||||
// Reducer state updates are batched/async; a ref lets a double-click on "Run
|
// Reducer state updates are batched/async; a ref lets a double-click on "Run
|
||||||
// approved command" be rejected synchronously, before React ever re-renders.
|
// approved command" be rejected synchronously, before React ever re-renders.
|
||||||
const executedApprovalIds = useRef<Set<string>>(new Set());
|
const executedApprovalIds = useRef<Set<string>>(new Set());
|
||||||
|
// Synchronous send lock: `state.sending` (reducer) drives the reactive UI
|
||||||
|
// disabled state, but reducer updates are batched/async, so two sendMessage
|
||||||
|
// calls issued in the same tick would both read the same stale `state`. This
|
||||||
|
// ref is the source of truth the guard checks against.
|
||||||
|
//
|
||||||
|
// It is intentionally never cleared directly by an event handler — a stale
|
||||||
|
// agent:end/error for a conversation other than the active one must not be
|
||||||
|
// able to re-arm the lock for a turn that is still genuinely in flight.
|
||||||
|
// Instead it is synchronized from `state.sending` below, which the reducer
|
||||||
|
// only flips on an *accepted* (conversation-scoped, or unconditional
|
||||||
|
// disconnect) transition — resolveScopedConversation already rejects a
|
||||||
|
// foreign-conversation agent:end/error by returning the unchanged state, so
|
||||||
|
// `state.sending` (and therefore this ref) cannot be released by one.
|
||||||
|
const sendLockRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
sendLockRef.current = state.sending;
|
||||||
|
}, [state.sending]);
|
||||||
|
|
||||||
|
// Same design as sendLockRef, mirroring `state.approvalRequestPending`:
|
||||||
|
// closes the same-tick double-dispatch race for approveCommand, and is
|
||||||
|
// synchronized from (not cleared by) the reducer's own accepted
|
||||||
|
// command:approval/error/disconnect transitions.
|
||||||
|
const approveLockRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
approveLockRef.current = state.approvalRequestPending;
|
||||||
|
}, [state.approvalRequestPending]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
@@ -331,8 +656,9 @@ export function useChatConnection(): ChatConnectionValue {
|
|||||||
dispatch({ type: 'server/agent:tool:start', payload });
|
dispatch({ type: 'server/agent:tool:start', payload });
|
||||||
const onToolEnd = (payload: ToolEndPayload): void =>
|
const onToolEnd = (payload: ToolEndPayload): void =>
|
||||||
dispatch({ type: 'server/agent:tool:end', payload });
|
dispatch({ type: 'server/agent:tool:end', payload });
|
||||||
const onAgentEnd = (payload: AgentEndPayload): void =>
|
const onAgentEnd = (payload: AgentEndPayload): void => {
|
||||||
dispatch({ type: 'server/agent:end', payload });
|
dispatch({ type: 'server/agent:end', payload });
|
||||||
|
};
|
||||||
const onSessionInfo = (payload: SessionInfoPayload): void =>
|
const onSessionInfo = (payload: SessionInfoPayload): void =>
|
||||||
dispatch({ type: 'server/session:info', payload });
|
dispatch({ type: 'server/session:info', payload });
|
||||||
const onCommandsManifest = (payload: CommandManifestPayload): void =>
|
const onCommandsManifest = (payload: CommandManifestPayload): void =>
|
||||||
@@ -343,7 +669,12 @@ export function useChatConnection(): ChatConnectionValue {
|
|||||||
dispatch({ type: 'server/command:approval', payload });
|
dispatch({ type: 'server/command:approval', payload });
|
||||||
const onSystemReload = (payload: SystemReloadPayload): void =>
|
const onSystemReload = (payload: SystemReloadPayload): void =>
|
||||||
dispatch({ type: 'server/system:reload', payload });
|
dispatch({ type: 'server/system:reload', payload });
|
||||||
const onError = (payload: ErrorPayload): void => dispatch({ type: 'server/error', payload });
|
const onError = (payload: ErrorPayload): void => {
|
||||||
|
dispatch({ type: 'server/error', payload });
|
||||||
|
};
|
||||||
|
const onDisconnect = (): void => {
|
||||||
|
dispatch({ type: 'local/disconnect' });
|
||||||
|
};
|
||||||
|
|
||||||
socket.on('message:ack', onMessageAck);
|
socket.on('message:ack', onMessageAck);
|
||||||
socket.on('agent:start', onAgentStart);
|
socket.on('agent:start', onAgentStart);
|
||||||
@@ -358,6 +689,7 @@ export function useChatConnection(): ChatConnectionValue {
|
|||||||
socket.on('command:approval', onCommandApproval);
|
socket.on('command:approval', onCommandApproval);
|
||||||
socket.on('system:reload', onSystemReload);
|
socket.on('system:reload', onSystemReload);
|
||||||
socket.on('error', onError);
|
socket.on('error', onError);
|
||||||
|
socket.on('disconnect', onDisconnect);
|
||||||
|
|
||||||
if (!socket.connected) {
|
if (!socket.connected) {
|
||||||
socket.connect();
|
socket.connect();
|
||||||
@@ -377,13 +709,15 @@ export function useChatConnection(): ChatConnectionValue {
|
|||||||
socket.off('command:approval', onCommandApproval);
|
socket.off('command:approval', onCommandApproval);
|
||||||
socket.off('system:reload', onSystemReload);
|
socket.off('system:reload', onSystemReload);
|
||||||
socket.off('error', onError);
|
socket.off('error', onError);
|
||||||
|
socket.off('disconnect', onDisconnect);
|
||||||
destroySocket();
|
destroySocket();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const actions: ChatConnectionActions = {
|
const actions: ChatConnectionActions = {
|
||||||
sendMessage: ({ content, provider, modelId }) => {
|
sendMessage: ({ content, provider, modelId }) => {
|
||||||
if (state.streaming) return;
|
if (sendLockRef.current || state.streaming || state.sending) return;
|
||||||
|
sendLockRef.current = true;
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (!socket.connected) socket.connect();
|
if (!socket.connected) socket.connect();
|
||||||
dispatch({ type: 'local/send', content });
|
dispatch({ type: 'local/send', content });
|
||||||
@@ -415,7 +749,8 @@ export function useChatConnection(): ChatConnectionValue {
|
|||||||
|
|
||||||
approveCommand: ({ command, args }) => {
|
approveCommand: ({ command, args }) => {
|
||||||
if (state.conversationId === null) return;
|
if (state.conversationId === null) return;
|
||||||
if (state.approvalRequestPending) return;
|
if (approveLockRef.current || state.approvalRequestPending) return;
|
||||||
|
approveLockRef.current = true;
|
||||||
dispatch({ type: 'local/approve-request', command, args });
|
dispatch({ type: 'local/approve-request', command, args });
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
socket.emit('command:approve', { conversationId: state.conversationId, command, args });
|
socket.emit('command:approve', { conversationId: state.conversationId, command, args });
|
||||||
@@ -423,14 +758,29 @@ export function useChatConnection(): ChatConnectionValue {
|
|||||||
|
|
||||||
runApprovedCommand: () => {
|
runApprovedCommand: () => {
|
||||||
const { conversationId, approval, pendingApproval } = state;
|
const { conversationId, approval, pendingApproval } = state;
|
||||||
if (conversationId === null || !approval?.success || !approval.approvalId) return;
|
if (conversationId === null) return;
|
||||||
|
// Defense-in-depth: the reducer already normalizes `success`/`approvalId`
|
||||||
|
// before storing `approval` (a matching command string alone is not
|
||||||
|
// proof of a genuine approval), but this action must never rely on that
|
||||||
|
// alone — a literal-`true` and non-empty-string check here too.
|
||||||
|
if (approval?.success !== true) 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)) return;
|
||||||
|
if (executedApprovalIds.current.size >= MAX_EXECUTED_APPROVAL_IDS) {
|
||||||
|
// Bounded dedup set: evict the oldest entry (Sets iterate in
|
||||||
|
// insertion order) so this can never grow without limit.
|
||||||
|
const oldest = executedApprovalIds.current.values().next().value;
|
||||||
|
if (oldest !== undefined) executedApprovalIds.current.delete(oldest);
|
||||||
|
}
|
||||||
executedApprovalIds.current.add(approval.approvalId);
|
executedApprovalIds.current.add(approval.approvalId);
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
socket.emit('command:execute', {
|
socket.emit('command:execute', {
|
||||||
conversationId,
|
conversationId,
|
||||||
command: approval.command,
|
// Sourced entirely from the frozen local pendingApproval, not the
|
||||||
|
// server-echoed approval payload — the equality guard above is
|
||||||
|
// defense-in-depth, not the source of truth for what gets executed.
|
||||||
|
command: pendingApproval.command,
|
||||||
args: pendingApproval.args,
|
args: pendingApproval.args,
|
||||||
approvalId: approval.approvalId,
|
approvalId: approval.approvalId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ChatRouteErrorBoundary } from './chat-error-boundary';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
function Boom(): never {
|
||||||
|
throw new Error('render blew up');
|
||||||
|
}
|
||||||
|
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChatRouteErrorBoundary', () => {
|
||||||
|
it('renders a recoverable, non-blank fallback when the /chat route element throws during render', async () => {
|
||||||
|
const routeObjects: RouteObject[] = [
|
||||||
|
{ path: '/chat', element: <Boom />, errorElement: <ChatRouteErrorBoundary /> },
|
||||||
|
];
|
||||||
|
const router = createMemoryRouter(routeObjects, { initialEntries: ['/chat'] });
|
||||||
|
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<RouterProvider router={router} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(container.textContent).not.toBe('');
|
||||||
|
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { useRouteError } from 'react-router-dom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/chat` renders live, server-driven state (streamed text, tool calls,
|
||||||
|
* manifests) that can carry malformed payloads no compile-time contract can
|
||||||
|
* fully rule out at every dereference site. This is the last line of
|
||||||
|
* defense: if something still throws during render, show a recoverable
|
||||||
|
* alert instead of leaving the user on a blank/white screen.
|
||||||
|
*/
|
||||||
|
export function ChatRouteErrorBoundary(): ReactElement {
|
||||||
|
useRouteError();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="alert" className="flex min-h-screen flex-col items-center justify-center gap-3 p-8">
|
||||||
|
<p className="text-sm font-medium">Something went wrong loading chat.</p>
|
||||||
|
<a href="/chat" className="text-sm underline">
|
||||||
|
Reload chat
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -233,7 +233,7 @@ describe('ChatPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows visible alert surfaces for a server error and a failed command result', async () => {
|
it('shows visible alert surfaces for a server error and a stable failure copy 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,14 +241,116 @@ describe('ChatPage', () => {
|
|||||||
conversationId: 'c1',
|
conversationId: 'c1',
|
||||||
command: 'model',
|
command: 'model',
|
||||||
success: false,
|
success: false,
|
||||||
message: 'unknown model id',
|
message: 'raw internal detail: stack trace at line 42',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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');
|
||||||
expect(alertText).toContain('unknown model id');
|
// Sanitized client copy, not the raw server-provided detail.
|
||||||
|
expect(alertText).toContain('Command failed.');
|
||||||
|
expect(alertText).not.toContain('raw internal detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmitRaw('session:info', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
availableThinkingLevels: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||||
|
const select = container.querySelector(
|
||||||
|
'select[aria-label="Thinking level"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
expect(select).toBeTruthy();
|
||||||
|
// A malformed level list still shows a visible, safe placeholder option
|
||||||
|
// rather than a silently empty select.
|
||||||
|
expect([...select.options]).toHaveLength(1);
|
||||||
|
expect(select.options[0]?.textContent).toMatch(/unavailable/i);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(select, '');
|
||||||
|
});
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'set:thinking')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders honest unavailable labels — not fabricated zeros — when agent:end usage has malformed/missing numeric fields', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmitRaw('agent:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
usage: {
|
||||||
|
provider: { nested: 'object' },
|
||||||
|
modelId: undefined,
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
tokens: { total: 'not-a-number' },
|
||||||
|
cost: undefined,
|
||||||
|
context: { percent: null, window: 200000 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const usage = container.querySelector('[aria-label="Usage"]');
|
||||||
|
expect(usage).toBeTruthy();
|
||||||
|
expect(usage?.textContent).toContain('tokens unavailable');
|
||||||
|
expect(usage?.textContent).toContain('cost unavailable');
|
||||||
|
expect(usage?.textContent).not.toContain('0 tokens');
|
||||||
|
expect(usage?.textContent).not.toContain('$0.0000');
|
||||||
|
expect(usage?.textContent).toContain('unknown/unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a safe fallback for message:ack when messageId is a malformed non-string value, without throwing', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
const status = [...container.querySelectorAll('[role="status"]')].find((node) =>
|
||||||
|
node.textContent?.includes('Message accepted'),
|
||||||
|
);
|
||||||
|
expect(status).toBeTruthy();
|
||||||
|
// A malformed messageId gets a stable, visible fallback — never blank,
|
||||||
|
// never the raw object.
|
||||||
|
expect(status?.textContent).toContain('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders safely and does not throw when system:reload.message is a malformed non-string value', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('system:reload', {
|
||||||
|
commands: [],
|
||||||
|
skills: [],
|
||||||
|
providers: [],
|
||||||
|
message: { bad: 'object' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
const status = container.querySelector('[role="status"]');
|
||||||
|
expect(status).toBeTruthy();
|
||||||
|
// A malformed reload message renders a stable, visible fallback rather
|
||||||
|
// than a silently empty status line.
|
||||||
|
expect(status?.textContent).toContain('Commands reloaded.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders safely and does not throw when a scoped error carries a malformed non-string error value', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmitRaw('error', { conversationId: 'c1', error: ['not', 'a', 'string'] });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
||||||
|
|||||||
@@ -2,10 +2,25 @@ import type { ReactElement } from 'react';
|
|||||||
import { CommandsPanel } from '@/spa/chat/commands-panel';
|
import { CommandsPanel } from '@/spa/chat/commands-panel';
|
||||||
import { Composer } from '@/spa/chat/composer';
|
import { Composer } from '@/spa/chat/composer';
|
||||||
import { MessageTranscript } from '@/spa/chat/message-transcript';
|
import { MessageTranscript } from '@/spa/chat/message-transcript';
|
||||||
|
import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
|
||||||
import { SessionPanel } from '@/spa/chat/session-panel';
|
import { SessionPanel } from '@/spa/chat/session-panel';
|
||||||
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
||||||
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
||||||
|
|
||||||
|
/** Renders a real value normally, but an honest "unavailable" label instead
|
||||||
|
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
|
||||||
|
* an unknown token count must never look the same. */
|
||||||
|
function formatTokens(value: unknown): string {
|
||||||
|
const tokens = asFiniteNumberOrNull(value);
|
||||||
|
return tokens === null ? 'tokens unavailable' : `${tokens} tokens`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same honesty guarantee as `formatTokens`, for cost. */
|
||||||
|
function formatCost(value: unknown): string {
|
||||||
|
const cost = asFiniteNumberOrNull(value);
|
||||||
|
return cost === null ? 'cost unavailable' : `$${cost.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatPage(): ReactElement {
|
export function ChatPage(): ReactElement {
|
||||||
const { state, actions } = useChatConnection();
|
const { state, actions } = useChatConnection();
|
||||||
const hasConversation = state.conversationId !== null;
|
const hasConversation = state.conversationId !== null;
|
||||||
@@ -18,19 +33,20 @@ export function ChatPage(): ReactElement {
|
|||||||
|
|
||||||
{state.systemReload ? (
|
{state.systemReload ? (
|
||||||
<div role="status" className="border-b px-4 py-2 text-sm">
|
<div role="status" className="border-b px-4 py-2 text-sm">
|
||||||
{state.systemReload.message}
|
{asString(state.systemReload.message)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.error ? (
|
{state.error ? (
|
||||||
<div role="alert" className="border-b px-4 py-2 text-sm">
|
<div role="alert" className="border-b px-4 py-2 text-sm">
|
||||||
{state.error}
|
{asString(state.error)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.ack ? (
|
{state.ack ? (
|
||||||
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
|
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
|
||||||
Message accepted · conversation {state.ack.conversationId} · id {state.ack.messageId}
|
Message accepted · conversation {asString(state.ack.conversationId, 'unknown')} · id{' '}
|
||||||
|
{asString(state.ack.messageId, 'unknown')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -48,8 +64,8 @@ export function ChatPage(): ReactElement {
|
|||||||
|
|
||||||
{state.usage ? (
|
{state.usage ? (
|
||||||
<div aria-label="Usage" className="px-4 pb-2 text-xs opacity-80">
|
<div aria-label="Usage" className="px-4 pb-2 text-xs opacity-80">
|
||||||
{state.usage.tokens.total} tokens · ${state.usage.cost.toFixed(4)} ·{' '}
|
{formatTokens(state.usage.tokens?.total)} · {formatCost(state.usage.cost)} ·{' '}
|
||||||
{state.usage.provider}/{state.usage.modelId}
|
{asString(state.usage.provider, 'unknown')}/{asString(state.usage.modelId, 'unknown')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -68,6 +84,7 @@ export function ChatPage(): ReactElement {
|
|||||||
onSend={actions.sendMessage}
|
onSend={actions.sendMessage}
|
||||||
onStop={actions.abort}
|
onStop={actions.abort}
|
||||||
streaming={state.streaming}
|
streaming={state.streaming}
|
||||||
|
sending={state.sending}
|
||||||
hasConversation={hasConversation}
|
hasConversation={hasConversation}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user