feat: P4.6 agent demo + escalate button + support seed/smoke (P4 complete)
apps/agent-demo (Vite): agent sets AVAILABLE, sees assigned tickets, opens the thread, replies. message-demo customer pane gains 'escalate to support' (via SupportProvider/useEscalate). seed-support + smoke-support prove the loop: escalate -> auto-assign -> agent reply -> customer receives. 45 tests; realtime/ inbox/support smokes all pass; both demos build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>IIOS P4 — Agent Demo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "agent-demo",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 5174",
|
||||||
|
"build": "vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@insignia/iios-kernel-client": "workspace:*",
|
||||||
|
"@insignia/iios-message-web": "workspace:*",
|
||||||
|
"@insignia/iios-support-web": "workspace:*",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
SupportProvider,
|
||||||
|
useAssignedTickets,
|
||||||
|
useAvailability,
|
||||||
|
useThread,
|
||||||
|
useMessages,
|
||||||
|
type Ticket,
|
||||||
|
} from '@insignia/iios-support-web';
|
||||||
|
|
||||||
|
const SERVICE = 'http://localhost:3200';
|
||||||
|
const APP_ID = 'portal-demo';
|
||||||
|
const AGENT_ID = 'agent1';
|
||||||
|
|
||||||
|
async function devToken(userId: string): Promise<string> {
|
||||||
|
const r = await fetch(`${SERVICE}/v1/dev/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ appId: APP_ID, userId, name: userId }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`devToken ${r.status} (service running with IIOS_DEV_TOKENS=1?)`);
|
||||||
|
return ((await r.json()) as { token: string }).token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentChat({ threadId }: { threadId: string }) {
|
||||||
|
const { open } = useThread();
|
||||||
|
useEffect(() => {
|
||||||
|
void open(threadId);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [threadId]);
|
||||||
|
const { messages, send } = useMessages(threadId);
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, padding: 12, fontFamily: 'sans-serif' }}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Thread {threadId.slice(0, 8)}</h3>
|
||||||
|
<div style={{ height: 280, overflow: 'auto', background: '#fafafa', padding: 8 }}>
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div key={m.id} style={{ padding: '2px 0' }}>
|
||||||
|
<b>{m.senderActorId.slice(0, 6)}:</b> {m.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={text}
|
||||||
|
placeholder="reply as agent + Enter"
|
||||||
|
style={{ width: '100%', padding: 6, marginTop: 8 }}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && text.trim()) {
|
||||||
|
void send(text.trim());
|
||||||
|
setText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentInner() {
|
||||||
|
const setAvailability = useAvailability();
|
||||||
|
const { tickets } = useAssignedTickets();
|
||||||
|
const [threadId, setThreadId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void setAvailability('AVAILABLE');
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', fontFamily: 'sans-serif' }}>
|
||||||
|
<div style={{ width: 240, borderRight: '1px solid #ddd', padding: 12 }}>
|
||||||
|
<b>Assigned tickets ({tickets.length})</b>
|
||||||
|
{tickets.length === 0 && <div style={{ color: '#999', marginTop: 6 }}>waiting for escalations…</div>}
|
||||||
|
{tickets.map((t: Ticket) => {
|
||||||
|
const tid = t.threadLinks?.[0]?.threadId ?? null;
|
||||||
|
return (
|
||||||
|
<div key={t.id} style={{ padding: '6px 0', borderBottom: '1px solid #eee' }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{t.subject}</div>
|
||||||
|
<div style={{ color: '#999' }}>{t.state}</div>
|
||||||
|
<button disabled={!tid} onClick={() => setThreadId(tid)}>
|
||||||
|
open thread
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{threadId ? <AgentChat threadId={threadId} /> : <div style={{ flex: 1, padding: 12 }}>Select a ticket.</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
void devToken(AGENT_ID).then(setToken);
|
||||||
|
}, []);
|
||||||
|
if (!token) return <div style={{ padding: 16, fontFamily: 'sans-serif' }}>loading agent…</div>;
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<h2 style={{ fontFamily: 'sans-serif' }}>IIOS P4 — Agent Dashboard ({AGENT_ID})</h2>
|
||||||
|
<SupportProvider serviceUrl={SERVICE} token={token}>
|
||||||
|
<AgentInner />
|
||||||
|
</SupportProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
const el = document.getElementById('root');
|
||||||
|
if (el) createRoot(el).render(<App />);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["react", "react-dom"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 5174 },
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"@insignia/iios-kernel-client": "workspace:*",
|
"@insignia/iios-kernel-client": "workspace:*",
|
||||||
"@insignia/iios-message-web": "workspace:*",
|
"@insignia/iios-message-web": "workspace:*",
|
||||||
"@insignia/iios-inbox-web": "workspace:*",
|
"@insignia/iios-inbox-web": "workspace:*",
|
||||||
|
"@insignia/iios-support-web": "workspace:*",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { MessageProvider, useThread, useMessages } from '@insignia/iios-message-web';
|
import { useThread, useMessages } from '@insignia/iios-message-web';
|
||||||
import { InboxProvider, useInbox } from '@insignia/iios-inbox-web';
|
import { InboxProvider, useInbox } from '@insignia/iios-inbox-web';
|
||||||
|
import { SupportProvider, useEscalate } from '@insignia/iios-support-web';
|
||||||
|
|
||||||
const SERVICE = 'http://localhost:3200';
|
const SERVICE = 'http://localhost:3200';
|
||||||
const APP_ID = 'portal-demo';
|
const APP_ID = 'portal-demo';
|
||||||
@@ -43,6 +44,7 @@ function ChatInner({
|
|||||||
}, [threadId]);
|
}, [threadId]);
|
||||||
|
|
||||||
const { messages, send, typing, typingUsers, markRead, reads } = useMessages(tid);
|
const { messages, send, typing, typingUsers, markRead, reads } = useMessages(tid);
|
||||||
|
const escalate = useEscalate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, border: '1px solid #ccc', borderRadius: 8, padding: 12, margin: 8, fontFamily: 'sans-serif' }}>
|
<div style={{ flex: 1, border: '1px solid #ccc', borderRadius: 8, padding: 12, margin: 8, fontFamily: 'sans-serif' }}>
|
||||||
@@ -80,6 +82,15 @@ function ChatInner({
|
|||||||
>
|
>
|
||||||
mark last read
|
mark last read
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
style={{ marginTop: 8, marginLeft: 4 }}
|
||||||
|
disabled={!tid}
|
||||||
|
onClick={() => {
|
||||||
|
if (tid) void escalate(tid, 'Support request');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
escalate to support
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -109,9 +120,9 @@ function Pane(props: { token: string | null; label: string; threadId: string | n
|
|||||||
<InboxProvider serviceUrl={SERVICE} token={props.token}>
|
<InboxProvider serviceUrl={SERVICE} token={props.token}>
|
||||||
<InboxSidebar />
|
<InboxSidebar />
|
||||||
</InboxProvider>
|
</InboxProvider>
|
||||||
<MessageProvider serviceUrl={SERVICE} token={props.token}>
|
<SupportProvider serviceUrl={SERVICE} token={props.token}>
|
||||||
<ChatInner label={props.label} threadId={props.threadId} onCreated={props.onCreated} />
|
<ChatInner label={props.label} threadId={props.threadId} onCreated={props.onCreated} />
|
||||||
</MessageProvider>
|
</SupportProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Seed a support queue + one available agent. Run against a running service.
|
||||||
|
import 'dotenv/config';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
||||||
|
const APP_ID = 'portal-demo';
|
||||||
|
const SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID];
|
||||||
|
const sign = (u) => jwt.sign({ sub: u, name: u, appId: APP_ID, orgId: `org_${APP_ID}` }, SECRET, { algorithm: 'HS256', expiresIn: '2h' });
|
||||||
|
|
||||||
|
async function api(path, method, token, body) {
|
||||||
|
const r = await fetch(`${SERVICE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentToken = sign(process.env.AGENT_ID ?? 'agent1');
|
||||||
|
const queue = await api('/v1/support/queues', 'POST', agentToken, { name: 'Support' });
|
||||||
|
await api(`/v1/support/queues/${queue.id}/members`, 'POST', agentToken);
|
||||||
|
await api('/v1/support/members/me/availability', 'PATCH', agentToken, { state: 'AVAILABLE' });
|
||||||
|
console.log(`seeded queue ${queue.id}; agent AVAILABLE`);
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// P4 support smoke: customer escalates a chat → ticket created + auto-assigned to
|
||||||
|
// an available agent → agent (now a thread participant) replies → customer sees it
|
||||||
|
// live. Requires: service running (relay timer on) + `node scripts/seed-support.mjs`.
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
||||||
|
const APP_ID = 'portal-demo';
|
||||||
|
const SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID];
|
||||||
|
const sign = (u) => jwt.sign({ sub: u, name: u, appId: APP_ID, orgId: `org_${APP_ID}` }, SECRET, { algorithm: 'HS256', expiresIn: '1h' });
|
||||||
|
const connect = (u) => io(`${SERVICE}/message`, { auth: { token: sign(u) }, transports: ['websocket'], forceNew: true });
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
||||||
|
|
||||||
|
async function api(path, method, token, body) {
|
||||||
|
const r = await fetch(`${SERVICE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
async function pollUntil(fn, ms = 8000) {
|
||||||
|
const end = Date.now() + ms;
|
||||||
|
while (Date.now() < end) { const v = await fn(); if (v) return v; await sleep(300); }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function waitForMessage(socket, pred, ms = 6000) {
|
||||||
|
return new Promise((res) => {
|
||||||
|
const to = setTimeout(() => res(null), ms);
|
||||||
|
const h = (m) => { if (pred(m)) { socket.off('message', h); clearTimeout(to); res(m); } };
|
||||||
|
socket.on('message', h);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const custToken = sign('cust');
|
||||||
|
const cust = connect('cust');
|
||||||
|
const agent = connect(process.env.AGENT_ID ?? 'agent1');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { threadId } = await cust.emitWithAck('open_thread', {});
|
||||||
|
await cust.emitWithAck('send_message', { threadId, content: 'my payment failed' });
|
||||||
|
|
||||||
|
const ticket = await api('/v1/support/escalate', 'POST', custToken, { threadId });
|
||||||
|
assert(!!ticket.id, `customer escalated → ticket ${ticket.id} (${ticket.state})`);
|
||||||
|
|
||||||
|
const assigned = await pollUntil(async () => {
|
||||||
|
const ts = await api('/v1/support/tickets?scope=mine', 'GET', custToken);
|
||||||
|
const t = ts.find((x) => x.id === ticket.id);
|
||||||
|
return t && t.state === 'OPEN' && t.assignedActorId ? t : null;
|
||||||
|
});
|
||||||
|
assert(assigned, 'ticket auto-assigned to an agent (state OPEN)');
|
||||||
|
|
||||||
|
await agent.emitWithAck('open_thread', { threadId });
|
||||||
|
const gotReply = waitForMessage(cust, (m) => m.content === 'Hi, I can help with that.');
|
||||||
|
await agent.emitWithAck('send_message', { threadId, content: 'Hi, I can help with that.' });
|
||||||
|
assert(await gotReply, 'customer received the agent reply live');
|
||||||
|
|
||||||
|
console.log('\nP4 support smoke: PASS');
|
||||||
|
} finally {
|
||||||
|
cust.close();
|
||||||
|
agent.close();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
Generated
+37
@@ -18,6 +18,40 @@ importers:
|
|||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.2.6(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0)
|
version: 3.2.6(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0)
|
||||||
|
|
||||||
|
apps/agent-demo:
|
||||||
|
dependencies:
|
||||||
|
'@insignia/iios-kernel-client':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/iios-kernel-client
|
||||||
|
'@insignia/iios-message-web':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/iios-message-web
|
||||||
|
'@insignia/iios-support-web':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/iios-support-web
|
||||||
|
react:
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.7
|
||||||
|
react-dom:
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.7(react@19.2.7)
|
||||||
|
devDependencies:
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.17
|
||||||
|
'@types/react-dom':
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.3(@types/react@19.2.17)
|
||||||
|
'@vitejs/plugin-react':
|
||||||
|
specifier: ^4.3.4
|
||||||
|
version: 4.7.0(vite@6.4.3(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0))
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.3
|
||||||
|
version: 5.9.3
|
||||||
|
vite:
|
||||||
|
specifier: ^6.0.7
|
||||||
|
version: 6.4.3(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0)
|
||||||
|
|
||||||
apps/message-demo:
|
apps/message-demo:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@insignia/iios-inbox-web':
|
'@insignia/iios-inbox-web':
|
||||||
@@ -29,6 +63,9 @@ importers:
|
|||||||
'@insignia/iios-message-web':
|
'@insignia/iios-message-web':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/iios-message-web
|
version: link:../../packages/iios-message-web
|
||||||
|
'@insignia/iios-support-web':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/iios-support-web
|
||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
|
|||||||
Reference in New Issue
Block a user