feat(web): add typed SPA chat
Bring the chat experience into the Vite/React-Router SPA on the exact typed Socket.IO /chat contract from @mosaicstack/types, replacing the /chat placeholder behind AuthGuard. Surfaces message:ack (with an accessible status), agent:start, streamed agent:text/agent:thinking, tool start/end status, agent:end with usage, session:info (thinking controls + routing decision), commands:manifest, command:result, command:approval (with a one-time approved-run affordance), system:reload (refreshing the rendered manifest), and error, and emits message/abort/set:thinking/command:execute/ command:approve with exact payloads. The gateway does not guarantee message:ack is the first event for a new conversation (session:info, and error on auth/session-creation failure, can both arrive first) — conversation-scoped events now adopt the conversation from whichever scoped event names it first while a send is pending, then filter everything else against that established conversation. A typed error stops streaming instead of leaving Stop stuck active; agent:end no longer appends an empty assistant turn when there is no text or thinking; and a second message can no longer be sent while a turn is streaming. Command approval is now integrity-checked end to end: only one command:approve request may be outstanding at a time (a concurrent request is ignored rather than overwriting the pending command/args), a stale or mismatched command:approval response cannot replace active approval state, and running an approved command clears its approval state immediately (via a ref, before React re-renders) so a double-click cannot replay command:execute. The `/chat` socket is now typed at a single boundary: apps/web/src/lib/ socket.ts narrows socket.io-client's untyped `io()` return value to `ChatSocket` (Socket<ServerToClientEvents, ClientToServerEvents>) once, at creation, via the one assertion the library's types force; every consumer (use-chat-connection.ts) then gets fully checked `on`/`emit` calls with no further casts. The shared contract types live in the new apps/web/src/lib/chat-contract.ts (replacing the old spa/chat/types.ts shim), which re-exports them via type-only imports resolved directly against packages/types/src (apps/web has no @mosaicstack/types package dependency, so this stays source-only and is erased at compile time — no package manifest or lockfile is touched). The two recorded-event test suites now drive a shared, typed fake socket (spa/chat/test-support/fake-chat-socket.ts) instead of an untyped `(event: string, payload: unknown)` harness, so a wrong event name or malformed payload fails to compile.
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
|
||||||
|
// @mosaicstack/types' source.
|
||||||
|
//
|
||||||
|
// `apps/web` does not declare `@mosaicstack/types` as a package dependency (P3 scope
|
||||||
|
// 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 {
|
||||||
|
AbortPayload,
|
||||||
|
AgentEndPayload,
|
||||||
|
AgentStartPayload,
|
||||||
|
AgentTextPayload,
|
||||||
|
AgentThinkingPayload,
|
||||||
|
ChatMessagePayload,
|
||||||
|
ClientToServerEvents,
|
||||||
|
ErrorPayload,
|
||||||
|
MessageAckPayload,
|
||||||
|
RoutingDecisionInfo,
|
||||||
|
ServerToClientEvents,
|
||||||
|
SessionInfoPayload,
|
||||||
|
SessionUsagePayload,
|
||||||
|
SetThinkingPayload,
|
||||||
|
ToolEndPayload,
|
||||||
|
ToolStartPayload,
|
||||||
|
} from '../../../../packages/types/src/chat/events';
|
||||||
|
import type {
|
||||||
|
CommandDef,
|
||||||
|
CommandManifest,
|
||||||
|
CommandManifestPayload,
|
||||||
|
SkillCommandDef,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
|
SlashCommandPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
SystemReloadPayload,
|
||||||
|
} from '../../../../packages/types/src/commands/index';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AbortPayload,
|
||||||
|
AgentEndPayload,
|
||||||
|
AgentStartPayload,
|
||||||
|
AgentTextPayload,
|
||||||
|
AgentThinkingPayload,
|
||||||
|
ChatMessagePayload,
|
||||||
|
ClientToServerEvents,
|
||||||
|
CommandDef,
|
||||||
|
CommandManifest,
|
||||||
|
CommandManifestPayload,
|
||||||
|
ErrorPayload,
|
||||||
|
MessageAckPayload,
|
||||||
|
RoutingDecisionInfo,
|
||||||
|
ServerToClientEvents,
|
||||||
|
SessionInfoPayload,
|
||||||
|
SessionUsagePayload,
|
||||||
|
SetThinkingPayload,
|
||||||
|
SkillCommandDef,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
|
SlashCommandPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
SystemReloadPayload,
|
||||||
|
ToolEndPayload,
|
||||||
|
ToolStartPayload,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The `/chat` namespace socket, narrowed to the exact typed event contract. */
|
||||||
|
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
import { io, type Socket } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
import type { ChatSocket } from './chat-contract';
|
||||||
|
|
||||||
let socket: Socket | null = null;
|
let socket: ChatSocket | null = null;
|
||||||
|
|
||||||
export function getSocket(): Socket {
|
export function getSocket(): ChatSocket {
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
|
// socket.io-client 4.8.3's `io()` factory declaration always returns the
|
||||||
|
// default unparameterized Socket (it accepts no <ListenEvents, EmitEvents>
|
||||||
|
// generics), so this one cast is the unavoidable boundary between that and the
|
||||||
|
// typed `/chat` contract. Every other call site uses the resulting ChatSocket
|
||||||
|
// with no further assertions.
|
||||||
socket = io('/chat', {
|
socket = io('/chat', {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
});
|
}) as unknown as ChatSocket;
|
||||||
|
|
||||||
// Reset singleton reference when socket is fully closed so the next
|
// Reset singleton reference when socket is fully closed so the next
|
||||||
// getSocket() call creates a fresh instance instead of returning a
|
// getSocket() call creates a fresh instance instead of returning a
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-r
|
|||||||
import { LoginPage } from '@/spa/pages/login';
|
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 { AuthGuard, GuestGuard } from '@/spa/guards';
|
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
|
|
||||||
@@ -34,7 +35,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: <Placeholder title="Chat" /> },
|
{ path: '/chat', element: <ChatPage /> },
|
||||||
{ 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,108 @@
|
|||||||
|
import { useState, type ReactElement } from 'react';
|
||||||
|
import type { PendingApproval } from './use-chat-connection';
|
||||||
|
import type {
|
||||||
|
CommandManifest,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
} from '@/lib/chat-contract';
|
||||||
|
|
||||||
|
interface CommandsPanelProps {
|
||||||
|
manifest: CommandManifest | null;
|
||||||
|
results: SlashCommandResultPayload[];
|
||||||
|
approval: SlashCommandApprovalResultPayload | null;
|
||||||
|
pendingApproval: PendingApproval | null;
|
||||||
|
hasConversation: boolean;
|
||||||
|
onExecute: (input: { command: string; args?: string }) => void;
|
||||||
|
onApprove: (input: { command: string; args?: string }) => void;
|
||||||
|
onRunApproved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandsPanel({
|
||||||
|
manifest,
|
||||||
|
results,
|
||||||
|
approval,
|
||||||
|
pendingApproval,
|
||||||
|
hasConversation,
|
||||||
|
onExecute,
|
||||||
|
onApprove,
|
||||||
|
onRunApproved,
|
||||||
|
}: CommandsPanelProps): ReactElement {
|
||||||
|
const [command, setCommand] = useState('');
|
||||||
|
const [args, setArgs] = useState('');
|
||||||
|
|
||||||
|
const canRunApproved =
|
||||||
|
!!approval?.success &&
|
||||||
|
!!approval.approvalId &&
|
||||||
|
!!pendingApproval &&
|
||||||
|
pendingApproval.command === approval.command;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
||||||
|
{manifest && manifest.commands.length > 0 ? (
|
||||||
|
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
||||||
|
{manifest.commands.map((cmd) => (
|
||||||
|
<li key={cmd.name}>
|
||||||
|
<strong>/{cmd.name}</strong> — {cmd.description}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
aria-label="Command name"
|
||||||
|
value={command}
|
||||||
|
onChange={(event) => setCommand(event.target.value)}
|
||||||
|
placeholder="command"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="Command arguments"
|
||||||
|
value={args}
|
||||||
|
onChange={(event) => setArgs(event.target.value)}
|
||||||
|
placeholder="args (optional)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasConversation || !command.trim()}
|
||||||
|
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
|
||||||
|
>
|
||||||
|
Run command
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasConversation || !command.trim()}
|
||||||
|
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
|
||||||
|
>
|
||||||
|
Request approval
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{approval ? (
|
||||||
|
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
{approval.success
|
||||||
|
? `Approved: /${approval.command}`
|
||||||
|
: `Approval denied: /${approval.command}`}
|
||||||
|
{approval.message ? ` — ${approval.message}` : ''}
|
||||||
|
</span>
|
||||||
|
{canRunApproved ? (
|
||||||
|
<button type="button" onClick={onRunApproved}>
|
||||||
|
Run approved command
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<ul aria-label="Command results" className="flex flex-col gap-1">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
||||||
|
/{result.command}: {result.success ? 'success' : 'failed'}
|
||||||
|
{result.message ? ` — ${result.message}` : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
||||||
|
|
||||||
|
interface ComposerProps {
|
||||||
|
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||||
|
onStop: () => void;
|
||||||
|
streaming: boolean;
|
||||||
|
hasConversation: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Composer({
|
||||||
|
onSend,
|
||||||
|
onStop,
|
||||||
|
streaming,
|
||||||
|
hasConversation,
|
||||||
|
}: ComposerProps): ReactElement {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [provider, setProvider] = useState('');
|
||||||
|
const [modelId, setModelId] = useState('');
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
if (streaming) return;
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
onSend({
|
||||||
|
content: trimmed,
|
||||||
|
provider: provider.trim() || undefined,
|
||||||
|
modelId: modelId.trim() || undefined,
|
||||||
|
});
|
||||||
|
setContent('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-2 border-t p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
aria-label="Provider"
|
||||||
|
value={provider}
|
||||||
|
onChange={(event) => setProvider(event.target.value)}
|
||||||
|
placeholder="Provider (optional)"
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="Model"
|
||||||
|
value={modelId}
|
||||||
|
onChange={(event) => setModelId(event.target.value)}
|
||||||
|
placeholder="Model (optional)"
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
aria-label="Message"
|
||||||
|
value={content}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Message… (Enter to send, Shift+Enter for a new line)"
|
||||||
|
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!content.trim() || streaming}
|
||||||
|
className="rounded px-3 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Stop"
|
||||||
|
disabled={!hasConversation || !streaming}
|
||||||
|
onClick={onStop}
|
||||||
|
className="rounded px-3 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { ChatTranscriptMessage } from './use-chat-connection';
|
||||||
|
|
||||||
|
interface MessageTranscriptProps {
|
||||||
|
messages: ChatTranscriptMessage[];
|
||||||
|
streaming: boolean;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageTranscript({
|
||||||
|
messages,
|
||||||
|
streaming,
|
||||||
|
text,
|
||||||
|
}: MessageTranscriptProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="Conversation"
|
||||||
|
className="flex flex-1 flex-col gap-3 overflow-y-auto p-4"
|
||||||
|
>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div key={message.id} data-role={message.role} className="whitespace-pre-wrap text-sm">
|
||||||
|
<span className="font-medium">{message.role === 'user' ? 'You' : 'Assistant'}: </span>
|
||||||
|
<span>{message.text}</span>
|
||||||
|
{message.thinking ? (
|
||||||
|
<div className="pt-1 text-xs italic opacity-70">{message.thinking}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{streaming ? (
|
||||||
|
<div data-role="assistant-streaming" className="whitespace-pre-wrap text-sm">
|
||||||
|
<span className="font-medium">Assistant: </span>
|
||||||
|
<span>{text || 'Thinking…'}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { SessionInfoPayload } from '@/lib/chat-contract';
|
||||||
|
|
||||||
|
interface SessionPanelProps {
|
||||||
|
sessionInfo: SessionInfoPayload | null;
|
||||||
|
onSetThinking: (level: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionPanel({
|
||||||
|
sessionInfo,
|
||||||
|
onSetThinking,
|
||||||
|
}: SessionPanelProps): ReactElement | null {
|
||||||
|
if (!sessionInfo) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Session info"
|
||||||
|
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<span>{sessionInfo.provider}</span>
|
||||||
|
<span>{sessionInfo.modelId}</span>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span>Thinking level</span>
|
||||||
|
<select
|
||||||
|
aria-label="Thinking level"
|
||||||
|
value={sessionInfo.thinkingLevel}
|
||||||
|
onChange={(event) => onSetThinking(event.target.value)}
|
||||||
|
>
|
||||||
|
{sessionInfo.availableThinkingLevels.map((level) => (
|
||||||
|
<option key={level} value={level}>
|
||||||
|
{level}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{sessionInfo.routingDecision ? (
|
||||||
|
<span title={sessionInfo.routingDecision.ruleName}>
|
||||||
|
{sessionInfo.routingDecision.reason}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { ClientToServerEvents, ServerToClientEvents } from '@/lib/chat-contract';
|
||||||
|
|
||||||
|
type ServerEvent = keyof ServerToClientEvents;
|
||||||
|
type ClientEvent = keyof ClientToServerEvents;
|
||||||
|
type ServerHandler<K extends ServerEvent> = ServerToClientEvents[K];
|
||||||
|
type ClientPayload<K extends ClientEvent> = Parameters<ClientToServerEvents[K]>[0];
|
||||||
|
|
||||||
|
export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
||||||
|
event: K;
|
||||||
|
payload: ClientPayload<K>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
||||||
|
export interface FakeChatSocket {
|
||||||
|
connected: boolean;
|
||||||
|
connect(): FakeChatSocket;
|
||||||
|
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||||
|
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||||
|
emit<K extends ClientEvent>(event: K, payload: ClientPayload<K>): FakeChatSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed in-memory stand-in for `getSocket()`. Unlike a bare
|
||||||
|
* `(event: string, payload: unknown) => void` mock, every public method here is
|
||||||
|
* checked against the real `/chat` contract — a typo'd event name or a payload
|
||||||
|
* missing a required field fails to compile instead of silently no-op'ing at
|
||||||
|
* runtime.
|
||||||
|
*/
|
||||||
|
export function createFakeChatSocket(): {
|
||||||
|
socket: FakeChatSocket;
|
||||||
|
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
|
||||||
|
emitted: EmittedEvent[];
|
||||||
|
serverEmit<K extends ServerEvent>(
|
||||||
|
event: K,
|
||||||
|
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||||
|
): void;
|
||||||
|
} {
|
||||||
|
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
||||||
|
const emitted: EmittedEvent[] = [];
|
||||||
|
|
||||||
|
// Internal storage is intentionally keyed loosely (the per-event handler shape
|
||||||
|
// varies by K, which a single Map can't express); the generic signatures on the
|
||||||
|
// exported `socket`/`serverEmit` above and below are what keep test call sites
|
||||||
|
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
||||||
|
const socket = {
|
||||||
|
connected: false,
|
||||||
|
connect: vi.fn(function connect(this: void) {
|
||||||
|
socket.connected = true;
|
||||||
|
return socket;
|
||||||
|
}),
|
||||||
|
on: vi.fn(function on(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||||
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||||
|
listeners.get(event)?.add(handler);
|
||||||
|
return socket;
|
||||||
|
}),
|
||||||
|
off: vi.fn(function off(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||||
|
listeners.get(event)?.delete(handler);
|
||||||
|
return socket;
|
||||||
|
}),
|
||||||
|
emit: vi.fn(function emit(this: void, event: ClientEvent, payload: unknown) {
|
||||||
|
emitted.push({ event, payload } as EmittedEvent);
|
||||||
|
return socket;
|
||||||
|
}),
|
||||||
|
} as unknown as FakeChatSocket;
|
||||||
|
|
||||||
|
function serverEmit<K extends ServerEvent>(
|
||||||
|
event: K,
|
||||||
|
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||||
|
): void {
|
||||||
|
for (const handler of listeners.get(event) ?? []) {
|
||||||
|
(handler as (payload: Parameters<ServerToClientEvents[K]>[0]) => void)(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { socket, listeners, emitted, serverEmit };
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { ToolCallState } from './use-chat-connection';
|
||||||
|
|
||||||
|
export function ToolCallList({ tools }: { tools: ToolCallState[] }): ReactElement | null {
|
||||||
|
if (tools.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<li key={tool.toolCallId} role={tool.status === 'error' ? 'alert' : 'status'}>
|
||||||
|
{tool.toolName} — {tool.status}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createFakeChatSocket } from './test-support/fake-chat-socket';
|
||||||
|
|
||||||
|
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
||||||
|
getSocketMock: vi.fn(),
|
||||||
|
destroySocketMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/socket', () => ({
|
||||||
|
getSocket: getSocketMock,
|
||||||
|
destroySocket: destroySocketMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useChatConnection, type ChatConnectionValue } from './use-chat-connection';
|
||||||
|
|
||||||
|
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||||
|
let latest: ChatConnectionValue | null;
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement | null;
|
||||||
|
|
||||||
|
function Harness(): null {
|
||||||
|
latest = useChatConnection();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
fake = createFakeChatSocket();
|
||||||
|
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
||||||
|
destroySocketMock.mockReset();
|
||||||
|
latest = null;
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<Harness />);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useChatConnection', () => {
|
||||||
|
it('establishes the active conversation from the first message:ack when message omitted conversationId', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.conversationId).toBe('c1');
|
||||||
|
expect(latest?.state.ack).toEqual({ conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a message:ack for a different conversation once one is already active', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c2', messageId: 'm2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.conversationId).toBe('c1');
|
||||||
|
expect(latest?.state.ack).toEqual({ conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates streamed agent:text chunks in order for the active conversation', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.streaming).toBe(true);
|
||||||
|
expect(latest?.state.text).toBe('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out agent:text events for a conversation that is not active', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hi' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'other', text: 'nope' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.text).toBe('Hi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates streamed agent:thinking text for the active conversation', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'step one. ' });
|
||||||
|
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'step two.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.thinking).toBe('step one. step two.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks a tool call from start through a successful end', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:tool:start', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 't1',
|
||||||
|
toolName: 'search',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(latest?.state.tools).toEqual([
|
||||||
|
{ toolCallId: 't1', toolName: 'search', status: 'running' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:tool:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 't1',
|
||||||
|
toolName: 'search',
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(latest?.state.tools).toEqual([
|
||||||
|
{ toolCallId: 't1', toolName: 'search', status: 'success' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks a tool call that ends in error', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmit('agent:tool:start', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 't1',
|
||||||
|
toolName: 'shell',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:tool:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 't1',
|
||||||
|
toolName: 'shell',
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.tools).toEqual([{ toolCallId: 't1', toolName: 'shell', status: 'error' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finalizes the streamed response into the transcript and captures usage on agent:end', async () => {
|
||||||
|
const usage = {
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
tokens: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, total: 30 },
|
||||||
|
cost: 0.01,
|
||||||
|
context: { percent: 5, window: 200000 },
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hello there' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:end', { conversationId: 'c1', usage });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.streaming).toBe(false);
|
||||||
|
expect(latest?.state.text).toBe('');
|
||||||
|
expect(latest?.state.usage).toEqual(usage);
|
||||||
|
expect(latest?.state.messages.at(-1)).toMatchObject({ role: 'assistant', text: 'Hello there' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records session:info including thinking controls and routing decision', async () => {
|
||||||
|
const sessionInfo = {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||||
|
routingDecision: {
|
||||||
|
model: 'claude',
|
||||||
|
provider: 'anthropic',
|
||||||
|
ruleName: 'default',
|
||||||
|
reason: 'default route',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('session:info', sessionInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.sessionInfo).toEqual(sessionInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the commands manifest regardless of active conversation', async () => {
|
||||||
|
const manifest = {
|
||||||
|
commands: [],
|
||||||
|
skills: [],
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('commands:manifest', { manifest });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.manifest).toEqual(manifest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a system:reload broadcast regardless of active conversation', async () => {
|
||||||
|
const reload = {
|
||||||
|
commands: [],
|
||||||
|
skills: [],
|
||||||
|
providers: ['anthropic'],
|
||||||
|
message: 'Commands reloaded',
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('system:reload', reload);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.systemReload).toEqual(reload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces an error for the active conversation and filters one for another conversation', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('error', { conversationId: 'other', error: 'ignored' });
|
||||||
|
});
|
||||||
|
expect(latest?.state.error).toBe(null);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('error', { conversationId: 'c1', error: 'boom' });
|
||||||
|
});
|
||||||
|
expect(latest?.state.error).toBe('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a failed command:result for the active conversation', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('command:result', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'model',
|
||||||
|
success: false,
|
||||||
|
message: 'unknown model',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.commandResults).toEqual([
|
||||||
|
{ conversationId: 'c1', command: 'model', success: false, message: 'unknown model' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pairs a successful command:approval with the pending request so it can be run with its approvalId', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:approve',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('command:approval', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.approval?.approvalId).toBe('ap1');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.runApprovedCommand();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:execute',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendMessage emits optional conversationId/provider/modelId and appends an optimistic user turn', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'hello', provider: 'anthropic', modelId: 'claude' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: {
|
||||||
|
conversationId: undefined,
|
||||||
|
content: 'hello',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(latest?.state.messages.at(-1)).toMatchObject({ role: 'user', text: 'hello' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendMessage includes the active conversationId once established', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'again' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: { conversationId: 'c1', content: 'again', provider: undefined, modelId: undefined },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abort emits abort with the active conversationId', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setThinking emits set:thinking with the requested level and active conversationId', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.setThinking('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'set:thinking',
|
||||||
|
payload: { conversationId: 'c1', level: 'high' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executeCommand emits command:execute with the exact command payload', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.executeCommand({ command: 'model', args: 'gpt-5' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:execute',
|
||||||
|
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts session:info as the active conversation when it arrives before message:ack, preserving it across the later ack', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'hi' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('session:info', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.conversationId).toBe('c1');
|
||||||
|
expect(latest?.state.sessionInfo?.provider).toBe('anthropic');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.ack).toEqual({ conversationId: 'c1', messageId: 'm1' });
|
||||||
|
expect(latest?.state.sessionInfo?.provider).toBe('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts a pre-ack error as the active conversation, surfaces it, and does not leave streaming stuck', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'hi' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('error', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
error: 'Failed to start agent session. Please try again.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.conversationId).toBe('c1');
|
||||||
|
expect(latest?.state.error).toBe('Failed to start agent session. Please try again.');
|
||||||
|
expect(latest?.state.streaming).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops streaming when a typed error arrives mid-turn', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
expect(latest?.state.streaming).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('error', { conversationId: 'c1', error: 'boom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.streaming).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not append an assistant message on agent:end when there is no text or thinking', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
const before = latest?.state.messages.length ?? 0;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.messages.length).toBe(before);
|
||||||
|
expect(latest?.state.streaming).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a second approval request while the first is still outstanding, preserving the original command and args', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.approveCommand({ command: 'deploy', args: 'staging' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.pendingApproval).toEqual({ command: 'deploy', args: 'prod' });
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('command:approval', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.runApprovedCommand();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:execute',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||||
|
});
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a stale command:approval response that does not match the pending request', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('command:approval', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'rollback',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'stale',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.approval).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits command:execute only once even when runApprovedCommand is invoked twice back to back', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.approveCommand({ command: 'deploy', args: 'prod' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('command:approval', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.runApprovedCommand();
|
||||||
|
latest?.actions.runApprovedCommand();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
|
||||||
|
expect(latest?.state.approval).toBeNull();
|
||||||
|
expect(latest?.state.pendingApproval).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores sendMessage while a turn is streaming', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latest?.actions.sendMessage({ content: 'too soon' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(0);
|
||||||
|
expect(latest?.state.messages.some((m) => m.text === 'too soon')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes the manifest commands from a system:reload broadcast', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('commands:manifest', {
|
||||||
|
manifest: {
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
aliases: [],
|
||||||
|
description: 'old',
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('system:reload', {
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: 'deploy',
|
||||||
|
aliases: [],
|
||||||
|
description: 'new',
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
providers: ['anthropic'],
|
||||||
|
message: 'Commands reloaded',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latest?.state.manifest?.commands.map((c) => c.name)).toEqual(['deploy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes every listener and tears down the socket on cleanup, using no network', async () => {
|
||||||
|
const registeredEvents = [...fake.listeners.keys()];
|
||||||
|
expect(registeredEvents.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
root = null;
|
||||||
|
|
||||||
|
for (const [, handlers] of fake.listeners) {
|
||||||
|
expect(handlers.size).toBe(0);
|
||||||
|
}
|
||||||
|
expect(destroySocketMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import { useEffect, useReducer, useRef } from 'react';
|
||||||
|
import { destroySocket, getSocket } from '@/lib/socket';
|
||||||
|
import type {
|
||||||
|
AgentEndPayload,
|
||||||
|
AgentStartPayload,
|
||||||
|
AgentTextPayload,
|
||||||
|
AgentThinkingPayload,
|
||||||
|
CommandManifest,
|
||||||
|
CommandManifestPayload,
|
||||||
|
ErrorPayload,
|
||||||
|
MessageAckPayload,
|
||||||
|
SessionInfoPayload,
|
||||||
|
SessionUsagePayload,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
SystemReloadPayload,
|
||||||
|
ToolEndPayload,
|
||||||
|
ToolStartPayload,
|
||||||
|
} from '@/lib/chat-contract';
|
||||||
|
|
||||||
|
export interface ToolCallState {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
status: 'running' | 'success' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatTranscriptMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
text: string;
|
||||||
|
thinking?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingApproval {
|
||||||
|
command: string;
|
||||||
|
args?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatConnectionState {
|
||||||
|
conversationId: string | null;
|
||||||
|
/** True once a message has been sent while no conversation is active yet, so the
|
||||||
|
* 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). */
|
||||||
|
pendingSend: boolean;
|
||||||
|
ack: MessageAckPayload | null;
|
||||||
|
streaming: boolean;
|
||||||
|
text: string;
|
||||||
|
thinking: string;
|
||||||
|
tools: ToolCallState[];
|
||||||
|
usage: SessionUsagePayload | null;
|
||||||
|
sessionInfo: SessionInfoPayload | null;
|
||||||
|
manifest: CommandManifest | null;
|
||||||
|
commandResults: SlashCommandResultPayload[];
|
||||||
|
approval: SlashCommandApprovalResultPayload | null;
|
||||||
|
pendingApproval: PendingApproval | null;
|
||||||
|
/** True while an approval request has been sent and no response has arrived yet. */
|
||||||
|
approvalRequestPending: boolean;
|
||||||
|
systemReload: SystemReloadPayload | null;
|
||||||
|
error: string | null;
|
||||||
|
messages: ChatTranscriptMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatConnectionActions {
|
||||||
|
sendMessage: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||||
|
abort: () => void;
|
||||||
|
setThinking: (level: string) => void;
|
||||||
|
executeCommand: (input: { command: string; args?: string }) => void;
|
||||||
|
approveCommand: (input: { command: string; args?: string }) => void;
|
||||||
|
runApprovedCommand: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatConnectionValue {
|
||||||
|
state: ChatConnectionState;
|
||||||
|
actions: ChatConnectionActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ChatConnectionState = {
|
||||||
|
conversationId: null,
|
||||||
|
pendingSend: false,
|
||||||
|
ack: null,
|
||||||
|
streaming: false,
|
||||||
|
text: '',
|
||||||
|
thinking: '',
|
||||||
|
tools: [],
|
||||||
|
usage: null,
|
||||||
|
sessionInfo: null,
|
||||||
|
manifest: null,
|
||||||
|
commandResults: [],
|
||||||
|
approval: null,
|
||||||
|
pendingApproval: null,
|
||||||
|
approvalRequestPending: false,
|
||||||
|
systemReload: null,
|
||||||
|
error: null,
|
||||||
|
messages: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: 'server/message:ack'; payload: MessageAckPayload }
|
||||||
|
| { type: 'server/agent:start'; payload: AgentStartPayload }
|
||||||
|
| { type: 'server/agent:text'; payload: AgentTextPayload }
|
||||||
|
| { type: 'server/agent:thinking'; payload: AgentThinkingPayload }
|
||||||
|
| { type: 'server/agent:tool:start'; payload: ToolStartPayload }
|
||||||
|
| { type: 'server/agent:tool:end'; payload: ToolEndPayload }
|
||||||
|
| { type: 'server/agent:end'; payload: AgentEndPayload }
|
||||||
|
| { type: 'server/session:info'; payload: SessionInfoPayload }
|
||||||
|
| { type: 'server/commands:manifest'; payload: CommandManifestPayload }
|
||||||
|
| { type: 'server/command:result'; payload: SlashCommandResultPayload }
|
||||||
|
| { type: 'server/command:approval'; payload: SlashCommandApprovalResultPayload }
|
||||||
|
| { type: 'server/system:reload'; payload: SystemReloadPayload }
|
||||||
|
| { type: 'server/error'; payload: ErrorPayload }
|
||||||
|
| { type: 'local/send'; content: string }
|
||||||
|
| { type: 'local/approve-request'; command: string; args?: string }
|
||||||
|
| { type: 'local/consume-approval' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves whether a scoped server event (one carrying a conversationId) belongs to
|
||||||
|
* the active conversation. The gateway does not guarantee message:ack is the first
|
||||||
|
* event for a new conversation (session:info, and error on auth/session-creation
|
||||||
|
* 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
|
||||||
|
* dropped. Once a conversation is active, only its own events pass.
|
||||||
|
*/
|
||||||
|
function resolveScopedConversation(
|
||||||
|
state: ChatConnectionState,
|
||||||
|
conversationId: string,
|
||||||
|
): { active: true; state: ChatConnectionState } | { active: false; state: null } {
|
||||||
|
if (state.conversationId === conversationId) {
|
||||||
|
return { active: true, state };
|
||||||
|
}
|
||||||
|
if (state.conversationId === null && state.pendingSend) {
|
||||||
|
return { active: true, state: { ...state, conversationId, pendingSend: false } };
|
||||||
|
}
|
||||||
|
return { active: false, state: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduce(state: ChatConnectionState, action: Action): ChatConnectionState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'server/message:ack': {
|
||||||
|
const { payload } = action;
|
||||||
|
if (state.conversationId === null) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
conversationId: payload.conversationId,
|
||||||
|
pendingSend: false,
|
||||||
|
ack: payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (payload.conversationId !== state.conversationId) return state;
|
||||||
|
return { ...state, ack: payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server/agent:start': {
|
||||||
|
const resolved = resolveScopedConversation(state, action.payload.conversationId);
|
||||||
|
if (!resolved.active) return state;
|
||||||
|
return { ...resolved.state, streaming: true, text: '', thinking: '', tools: [], error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server/agent:text': {
|
||||||
|
const resolved = resolveScopedConversation(state, action.payload.conversationId);
|
||||||
|
if (!resolved.active) return state;
|
||||||
|
return { ...resolved.state, text: resolved.state.text + action.payload.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server/agent:thinking': {
|
||||||
|
const resolved = resolveScopedConversation(state, action.payload.conversationId);
|
||||||
|
if (!resolved.active) return state;
|
||||||
|
return { ...resolved.state, thinking: resolved.state.thinking + action.payload.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server/agent:tool:start': {
|
||||||
|
const { payload } = action;
|
||||||
|
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||||
|
if (!resolved.active) return state;
|
||||||
|
const tool: ToolCallState = {
|
||||||
|
toolCallId: payload.toolCallId,
|
||||||
|
toolName: payload.toolName,
|
||||||
|
status: 'running',
|
||||||
|
};
|
||||||
|
return { ...resolved.state, tools: [...resolved.state.tools, tool] };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server/agent:tool:end': {
|
||||||
|
const { payload } = action;
|
||||||
|
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||||
|
if (!resolved.active) return state;
|
||||||
|
return {
|
||||||
|
...resolved.state,
|
||||||
|
tools: resolved.state.tools.map((tool) =>
|
||||||
|
tool.toolCallId === payload.toolCallId
|
||||||
|
? { ...tool, status: payload.isError ? 'error' : 'success' }
|
||||||
|
: tool,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server/agent:end': {
|
||||||
|
const { payload } = action;
|
||||||
|
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||||
|
if (!resolved.active) return state;
|
||||||
|
const next = resolved.state;
|
||||||
|
const hasContent = next.text.length > 0 || next.thinking.length > 0;
|
||||||
|
const messages = hasContent
|
||||||
|
? [
|
||||||
|
...next.messages,
|
||||||
|
{
|
||||||
|
id: `assistant-${payload.conversationId}-${next.messages.length}`,
|
||||||
|
role: 'assistant' as const,
|
||||||
|
text: next.text,
|
||||||
|
thinking: next.thinking || undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: next.messages;
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
streaming: false,
|
||||||
|
text: '',
|
||||||
|
thinking: '',
|
||||||
|
usage: payload.usage ?? next.usage,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server/session:info': {
|
||||||
|
const resolved = resolveScopedConversation(state, action.payload.conversationId);
|
||||||
|
if (!resolved.active) return state;
|
||||||
|
return { ...resolved.state, sessionInfo: action.payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server/commands:manifest': {
|
||||||
|
return { ...state, manifest: action.payload.manifest };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server/command:result': {
|
||||||
|
const { payload } = action;
|
||||||
|
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||||
|
if (!resolved.active) return state;
|
||||||
|
return { ...resolved.state, commandResults: [...resolved.state.commandResults, payload] };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server/command:approval': {
|
||||||
|
const { payload } = action;
|
||||||
|
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||||
|
if (!resolved.active) return state;
|
||||||
|
const next = resolved.state;
|
||||||
|
if (!next.pendingApproval || next.pendingApproval.command !== payload.command) {
|
||||||
|
// 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.
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return { ...next, approval: payload, approvalRequestPending: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server/system:reload': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
systemReload: action.payload,
|
||||||
|
manifest: {
|
||||||
|
commands: action.payload.commands,
|
||||||
|
skills: action.payload.skills,
|
||||||
|
version: state.manifest?.version ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server/error': {
|
||||||
|
const { payload } = action;
|
||||||
|
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||||
|
if (!resolved.active) return state;
|
||||||
|
return { ...resolved.state, error: payload.error, streaming: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'local/send': {
|
||||||
|
const message: ChatTranscriptMessage = {
|
||||||
|
id: `user-${Date.now()}-${state.messages.length}`,
|
||||||
|
role: 'user',
|
||||||
|
text: action.content,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
messages: [...state.messages, message],
|
||||||
|
error: null,
|
||||||
|
pendingSend: state.conversationId === null ? true : state.pendingSend,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'local/approve-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
|
||||||
|
// command+args pair with a mismatched one.
|
||||||
|
if (state.approvalRequestPending) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
pendingApproval: { command: action.command, args: action.args },
|
||||||
|
approvalRequestPending: true,
|
||||||
|
approval: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'local/consume-approval': {
|
||||||
|
return { ...state, approval: null, pendingApproval: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the `/chat` socket for the lifetime of the consuming page: subscribes
|
||||||
|
* once, narrows the socket to the typed contract, and tears the socket down
|
||||||
|
* on cleanup so the next visit gets a fresh authenticated connection.
|
||||||
|
*/
|
||||||
|
export function useChatConnection(): ChatConnectionValue {
|
||||||
|
const [state, dispatch] = useReducer(reduce, initialState);
|
||||||
|
// Reducer state updates are batched/async; a ref lets a double-click on "Run
|
||||||
|
// approved command" be rejected synchronously, before React ever re-renders.
|
||||||
|
const executedApprovalIds = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket();
|
||||||
|
|
||||||
|
const onMessageAck = (payload: MessageAckPayload): void =>
|
||||||
|
dispatch({ type: 'server/message:ack', payload });
|
||||||
|
const onAgentStart = (payload: AgentStartPayload): void =>
|
||||||
|
dispatch({ type: 'server/agent:start', payload });
|
||||||
|
const onAgentText = (payload: AgentTextPayload): void =>
|
||||||
|
dispatch({ type: 'server/agent:text', payload });
|
||||||
|
const onAgentThinking = (payload: AgentThinkingPayload): void =>
|
||||||
|
dispatch({ type: 'server/agent:thinking', payload });
|
||||||
|
const onToolStart = (payload: ToolStartPayload): void =>
|
||||||
|
dispatch({ type: 'server/agent:tool:start', payload });
|
||||||
|
const onToolEnd = (payload: ToolEndPayload): void =>
|
||||||
|
dispatch({ type: 'server/agent:tool:end', payload });
|
||||||
|
const onAgentEnd = (payload: AgentEndPayload): void =>
|
||||||
|
dispatch({ type: 'server/agent:end', payload });
|
||||||
|
const onSessionInfo = (payload: SessionInfoPayload): void =>
|
||||||
|
dispatch({ type: 'server/session:info', payload });
|
||||||
|
const onCommandsManifest = (payload: CommandManifestPayload): void =>
|
||||||
|
dispatch({ type: 'server/commands:manifest', payload });
|
||||||
|
const onCommandResult = (payload: SlashCommandResultPayload): void =>
|
||||||
|
dispatch({ type: 'server/command:result', payload });
|
||||||
|
const onCommandApproval = (payload: SlashCommandApprovalResultPayload): void =>
|
||||||
|
dispatch({ type: 'server/command:approval', payload });
|
||||||
|
const onSystemReload = (payload: SystemReloadPayload): void =>
|
||||||
|
dispatch({ type: 'server/system:reload', payload });
|
||||||
|
const onError = (payload: ErrorPayload): void => dispatch({ type: 'server/error', payload });
|
||||||
|
|
||||||
|
socket.on('message:ack', onMessageAck);
|
||||||
|
socket.on('agent:start', onAgentStart);
|
||||||
|
socket.on('agent:text', onAgentText);
|
||||||
|
socket.on('agent:thinking', onAgentThinking);
|
||||||
|
socket.on('agent:tool:start', onToolStart);
|
||||||
|
socket.on('agent:tool:end', onToolEnd);
|
||||||
|
socket.on('agent:end', onAgentEnd);
|
||||||
|
socket.on('session:info', onSessionInfo);
|
||||||
|
socket.on('commands:manifest', onCommandsManifest);
|
||||||
|
socket.on('command:result', onCommandResult);
|
||||||
|
socket.on('command:approval', onCommandApproval);
|
||||||
|
socket.on('system:reload', onSystemReload);
|
||||||
|
socket.on('error', onError);
|
||||||
|
|
||||||
|
if (!socket.connected) {
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('message:ack', onMessageAck);
|
||||||
|
socket.off('agent:start', onAgentStart);
|
||||||
|
socket.off('agent:text', onAgentText);
|
||||||
|
socket.off('agent:thinking', onAgentThinking);
|
||||||
|
socket.off('agent:tool:start', onToolStart);
|
||||||
|
socket.off('agent:tool:end', onToolEnd);
|
||||||
|
socket.off('agent:end', onAgentEnd);
|
||||||
|
socket.off('session:info', onSessionInfo);
|
||||||
|
socket.off('commands:manifest', onCommandsManifest);
|
||||||
|
socket.off('command:result', onCommandResult);
|
||||||
|
socket.off('command:approval', onCommandApproval);
|
||||||
|
socket.off('system:reload', onSystemReload);
|
||||||
|
socket.off('error', onError);
|
||||||
|
destroySocket();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const actions: ChatConnectionActions = {
|
||||||
|
sendMessage: ({ content, provider, modelId }) => {
|
||||||
|
if (state.streaming) return;
|
||||||
|
const socket = getSocket();
|
||||||
|
if (!socket.connected) socket.connect();
|
||||||
|
dispatch({ type: 'local/send', content });
|
||||||
|
socket.emit('message', {
|
||||||
|
conversationId: state.conversationId ?? undefined,
|
||||||
|
content,
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
abort: () => {
|
||||||
|
if (state.conversationId === null) return;
|
||||||
|
const socket = getSocket();
|
||||||
|
socket.emit('abort', { conversationId: state.conversationId });
|
||||||
|
},
|
||||||
|
|
||||||
|
setThinking: (level) => {
|
||||||
|
if (state.conversationId === null) return;
|
||||||
|
const socket = getSocket();
|
||||||
|
socket.emit('set:thinking', { conversationId: state.conversationId, level });
|
||||||
|
},
|
||||||
|
|
||||||
|
executeCommand: ({ command, args }) => {
|
||||||
|
if (state.conversationId === null) return;
|
||||||
|
const socket = getSocket();
|
||||||
|
socket.emit('command:execute', { conversationId: state.conversationId, command, args });
|
||||||
|
},
|
||||||
|
|
||||||
|
approveCommand: ({ command, args }) => {
|
||||||
|
if (state.conversationId === null) return;
|
||||||
|
if (state.approvalRequestPending) return;
|
||||||
|
dispatch({ type: 'local/approve-request', command, args });
|
||||||
|
const socket = getSocket();
|
||||||
|
socket.emit('command:approve', { conversationId: state.conversationId, command, args });
|
||||||
|
},
|
||||||
|
|
||||||
|
runApprovedCommand: () => {
|
||||||
|
const { conversationId, approval, pendingApproval } = state;
|
||||||
|
if (conversationId === null || !approval?.success || !approval.approvalId) return;
|
||||||
|
if (!pendingApproval || pendingApproval.command !== approval.command) return;
|
||||||
|
if (executedApprovalIds.current.has(approval.approvalId)) return;
|
||||||
|
executedApprovalIds.current.add(approval.approvalId);
|
||||||
|
const socket = getSocket();
|
||||||
|
socket.emit('command:execute', {
|
||||||
|
conversationId,
|
||||||
|
command: approval.command,
|
||||||
|
args: pendingApproval.args,
|
||||||
|
approvalId: approval.approvalId,
|
||||||
|
});
|
||||||
|
dispatch({ type: 'local/consume-approval' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { state, actions };
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket';
|
||||||
|
|
||||||
|
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
||||||
|
getSocketMock: vi.fn(),
|
||||||
|
destroySocketMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/socket', () => ({
|
||||||
|
getSocket: getSocketMock,
|
||||||
|
destroySocket: destroySocketMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ChatPage } from './chat';
|
||||||
|
|
||||||
|
function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
||||||
|
const proto =
|
||||||
|
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||||||
|
setter?.call(el, value);
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectValue(el: HTMLSelectElement, value: string): void {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
||||||
|
setter?.call(el, value);
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||||
|
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||||
|
candidate.textContent?.includes(text),
|
||||||
|
);
|
||||||
|
if (!button) throw new Error(`Button with text "${text}" not found`);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
fake = createFakeChatSocket();
|
||||||
|
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
||||||
|
destroySocketMock.mockReset();
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<ChatPage />);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChatPage', () => {
|
||||||
|
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'pondering…' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo!' });
|
||||||
|
fake.serverEmit('agent:tool:start', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 't1',
|
||||||
|
toolName: 'web_search',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('pondering…');
|
||||||
|
expect(container.textContent).toContain('Hello!');
|
||||||
|
expect(container.textContent).toContain('web_search');
|
||||||
|
expect(container.textContent).toMatch(/running/i);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:tool:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 't1',
|
||||||
|
toolName: 'web_search',
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
fake.serverEmit('agent:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
usage: {
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
tokens: { input: 12, output: 34, cacheRead: 0, cacheWrite: 0, total: 46 },
|
||||||
|
cost: 0.02,
|
||||||
|
context: { percent: 3, window: 200000 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toMatch(/success/i);
|
||||||
|
expect(container.textContent).toContain('Hello!');
|
||||||
|
expect(container.textContent).toMatch(/46/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the commands manifest and session info, and lets the user pick a thinking level', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('commands:manifest', {
|
||||||
|
manifest: {
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
aliases: ['m'],
|
||||||
|
description: 'Change the active model',
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fake.serverEmit('session:info', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||||
|
routingDecision: {
|
||||||
|
model: 'claude',
|
||||||
|
provider: 'anthropic',
|
||||||
|
ruleName: 'default',
|
||||||
|
reason: 'default routing',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('model');
|
||||||
|
expect(container.textContent).toContain('Change the active model');
|
||||||
|
expect(container.textContent).toContain('anthropic');
|
||||||
|
expect(container.textContent).toContain('default routing');
|
||||||
|
|
||||||
|
const select = container.querySelector(
|
||||||
|
'select[aria-label="Thinking level"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
expect(select).toBeTruthy();
|
||||||
|
expect([...select.options].map((o) => o.value)).toEqual(['low', 'medium', 'high']);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(select, 'high');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'set:thinking',
|
||||||
|
payload: { conversationId: 'c1', level: 'high' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes and approves commands with exact payloads and surfaces the approval affordance', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandInput = container.querySelector(
|
||||||
|
'input[aria-label="Command name"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const argsInput = container.querySelector(
|
||||||
|
'input[aria-label="Command arguments"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(commandInput, 'model');
|
||||||
|
setValue(argsInput, 'gpt-5');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Run command').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:execute',
|
||||||
|
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(commandInput, 'deploy');
|
||||||
|
setValue(argsInput, 'prod');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Request approval').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:approve',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('command:approval', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toMatch(/approved/i);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Run approved command').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:execute',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows visible alert surfaces for a server error and a failed command result', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
|
||||||
|
fake.serverEmit('command:result', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'model',
|
||||||
|
success: false,
|
||||||
|
message: 'unknown model id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||||
|
const alertText = alerts.map((node) => node.textContent).join(' ');
|
||||||
|
expect(alertText).toContain('The model is unavailable');
|
||||||
|
expect(alertText).toContain('unknown model id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
const providerInput = container.querySelector(
|
||||||
|
'input[aria-label="Provider"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
|
||||||
|
|
||||||
|
const stopButtonBefore = container.querySelector(
|
||||||
|
'button[aria-label="Stop"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(stopButtonBefore.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'hello there');
|
||||||
|
setValue(providerInput, 'anthropic');
|
||||||
|
setValue(modelInput, 'claude');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: {
|
||||||
|
conversationId: undefined,
|
||||||
|
content: 'hello there',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain('hello there');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopButtonDuring = container.querySelector(
|
||||||
|
'button[aria-label="Stop"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(stopButtonDuring.disabled).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
stopButtonDuring.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'hello');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('session:info', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||||
|
expect(container.textContent).toContain('anthropic');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||||
|
expect(container.textContent).toContain('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a pre-ack error as an alert without leaving the Stop control stuck active', async () => {
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'hello');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('error', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
error: 'Failed to start agent session. Please try again.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||||
|
expect(alerts.some((node) => node.textContent?.includes('Failed to start agent session'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopButton = container.querySelector('button[aria-label="Stop"]') as HTMLButtonElement;
|
||||||
|
expect(stopButton.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an accessible status once the message is acknowledged', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const statuses = [...container.querySelectorAll('[role="status"]')];
|
||||||
|
expect(statuses.some((node) => node.textContent?.includes('m1'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders finalized thinking text in the transcript after agent:end, not only while streaming', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'reasoning about it' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Done.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('reasoning about it');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('reasoning about it');
|
||||||
|
expect(container.textContent).toContain('Done.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a concurrent approval request and only executes the approved command once', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandInput = container.querySelector(
|
||||||
|
'input[aria-label="Command name"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const argsInput = container.querySelector(
|
||||||
|
'input[aria-label="Command arguments"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(commandInput, 'deploy');
|
||||||
|
setValue(argsInput, 'prod');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Request approval').click();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
setValue(argsInput, 'staging');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Request approval').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:approve',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('command:approval', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Run approved command').click();
|
||||||
|
findButton(container, 'Run approved command').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:execute',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables sending a second message while a turn is streaming', async () => {
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'first');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendButton = findButton(container, 'Send');
|
||||||
|
expect(sendButton.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'second');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
||||||
|
expect(fake.listeners.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
root = null;
|
||||||
|
|
||||||
|
for (const [, handlers] of fake.listeners) {
|
||||||
|
expect(handlers.size).toBe(0);
|
||||||
|
}
|
||||||
|
expect(destroySocketMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { CommandsPanel } from '@/spa/chat/commands-panel';
|
||||||
|
import { Composer } from '@/spa/chat/composer';
|
||||||
|
import { MessageTranscript } from '@/spa/chat/message-transcript';
|
||||||
|
import { SessionPanel } from '@/spa/chat/session-panel';
|
||||||
|
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
||||||
|
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
||||||
|
|
||||||
|
export function ChatPage(): ReactElement {
|
||||||
|
const { state, actions } = useChatConnection();
|
||||||
|
const hasConversation = state.conversationId !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-3.5rem)] min-h-0 flex-col overflow-hidden md:h-screen">
|
||||||
|
<header className="border-b px-4 py-3">
|
||||||
|
<h1 className="text-lg font-semibold">Chat</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{state.systemReload ? (
|
||||||
|
<div role="status" className="border-b px-4 py-2 text-sm">
|
||||||
|
{state.systemReload.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.error ? (
|
||||||
|
<div role="alert" className="border-b px-4 py-2 text-sm">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.ack ? (
|
||||||
|
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
|
||||||
|
Message accepted · conversation {state.ack.conversationId} · id {state.ack.messageId}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SessionPanel sessionInfo={state.sessionInfo} onSetThinking={actions.setThinking} />
|
||||||
|
|
||||||
|
<MessageTranscript messages={state.messages} streaming={state.streaming} text={state.text} />
|
||||||
|
|
||||||
|
{state.thinking ? (
|
||||||
|
<section aria-label="Thinking" className="px-4 pb-2 text-xs italic opacity-80">
|
||||||
|
{state.thinking}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ToolCallList tools={state.tools} />
|
||||||
|
|
||||||
|
{state.usage ? (
|
||||||
|
<div aria-label="Usage" className="px-4 pb-2 text-xs opacity-80">
|
||||||
|
{state.usage.tokens.total} tokens · ${state.usage.cost.toFixed(4)} ·{' '}
|
||||||
|
{state.usage.provider}/{state.usage.modelId}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={state.manifest}
|
||||||
|
results={state.commandResults}
|
||||||
|
approval={state.approval}
|
||||||
|
pendingApproval={state.pendingApproval}
|
||||||
|
hasConversation={hasConversation}
|
||||||
|
onExecute={actions.executeCommand}
|
||||||
|
onApprove={actions.approveCommand}
|
||||||
|
onRunApproved={actions.runApprovedCommand}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Composer
|
||||||
|
onSend={actions.sendMessage}
|
||||||
|
onStop={actions.abort}
|
||||||
|
streaming={state.streaming}
|
||||||
|
hasConversation={hasConversation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { RouteObject } from 'react-router-dom';
|
import type { RouteObject } from 'react-router-dom';
|
||||||
import { routes } from '@/routes';
|
import { routes } from '@/routes';
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
|
import { ChatPage } from '@/spa/pages/chat';
|
||||||
|
|
||||||
function collectPaths(routeObjects: RouteObject[]): string[] {
|
function collectPaths(routeObjects: RouteObject[]): string[] {
|
||||||
return routeObjects.flatMap((route) => [
|
return routeObjects.flatMap((route) => [
|
||||||
@@ -55,4 +56,15 @@ describe('SPA route table', () => {
|
|||||||
expect(element.type).not.toBe(Placeholder);
|
expect(element.type).not.toBe(Placeholder);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('renders the real chat page instead of the P1 placeholder at /chat, inside the authenticated group', () => {
|
||||||
|
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
||||||
|
expect(authPaths).toContain('/chat');
|
||||||
|
|
||||||
|
const element = findRoute(routes, '/chat')?.element;
|
||||||
|
expect(isValidElement(element)).toBe(true);
|
||||||
|
if (!isValidElement(element)) throw new Error('Missing route element for /chat');
|
||||||
|
expect(element.type).not.toBe(Placeholder);
|
||||||
|
expect(element.type).toBe(ChatPage);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user