From 7441d0ec36deed909b1f43371fd79c7c4c2bcbe6 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 1 Jul 2026 12:09:29 +0530 Subject: [PATCH] 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) --- apps/agent-demo/index.html | 12 ++ apps/agent-demo/package.json | 24 ++++ apps/agent-demo/src/App.tsx | 106 ++++++++++++++++++ apps/agent-demo/src/main.tsx | 5 + apps/agent-demo/tsconfig.json | 12 ++ apps/agent-demo/vite.config.ts | 7 ++ apps/message-demo/package.json | 1 + apps/message-demo/src/App.tsx | 17 ++- .../iios-service/scripts/seed-support.mjs | 25 +++++ .../iios-service/scripts/smoke-support.mjs | 66 +++++++++++ pnpm-lock.yaml | 37 ++++++ 11 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 apps/agent-demo/index.html create mode 100644 apps/agent-demo/package.json create mode 100644 apps/agent-demo/src/App.tsx create mode 100644 apps/agent-demo/src/main.tsx create mode 100644 apps/agent-demo/tsconfig.json create mode 100644 apps/agent-demo/vite.config.ts create mode 100644 packages/iios-service/scripts/seed-support.mjs create mode 100644 packages/iios-service/scripts/smoke-support.mjs diff --git a/apps/agent-demo/index.html b/apps/agent-demo/index.html new file mode 100644 index 0000000..eba1f5f --- /dev/null +++ b/apps/agent-demo/index.html @@ -0,0 +1,12 @@ + + + + + + IIOS P4 — Agent Demo + + +
+ + + diff --git a/apps/agent-demo/package.json b/apps/agent-demo/package.json new file mode 100644 index 0000000..0793e11 --- /dev/null +++ b/apps/agent-demo/package.json @@ -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" + } +} diff --git a/apps/agent-demo/src/App.tsx b/apps/agent-demo/src/App.tsx new file mode 100644 index 0000000..b871efc --- /dev/null +++ b/apps/agent-demo/src/App.tsx @@ -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 { + 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 ( +
+

Thread {threadId.slice(0, 8)}

+
+ {messages.map((m) => ( +
+ {m.senderActorId.slice(0, 6)}: {m.content} +
+ ))} +
+ setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && text.trim()) { + void send(text.trim()); + setText(''); + } + }} + /> +
+ ); +} + +function AgentInner() { + const setAvailability = useAvailability(); + const { tickets } = useAssignedTickets(); + const [threadId, setThreadId] = useState(null); + + useEffect(() => { + void setAvailability('AVAILABLE'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ Assigned tickets ({tickets.length}) + {tickets.length === 0 &&
waiting for escalations…
} + {tickets.map((t: Ticket) => { + const tid = t.threadLinks?.[0]?.threadId ?? null; + return ( +
+
{t.subject}
+
{t.state}
+ +
+ ); + })} +
+ {threadId ? :
Select a ticket.
} +
+ ); +} + +export function App() { + const [token, setToken] = useState(null); + useEffect(() => { + void devToken(AGENT_ID).then(setToken); + }, []); + if (!token) return
loading agent…
; + return ( +
+

IIOS P4 — Agent Dashboard ({AGENT_ID})

+ + + +
+ ); +} diff --git a/apps/agent-demo/src/main.tsx b/apps/agent-demo/src/main.tsx new file mode 100644 index 0000000..17a6dd8 --- /dev/null +++ b/apps/agent-demo/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +const el = document.getElementById('root'); +if (el) createRoot(el).render(); diff --git a/apps/agent-demo/tsconfig.json b/apps/agent-demo/tsconfig.json new file mode 100644 index 0000000..1a0c48c --- /dev/null +++ b/apps/agent-demo/tsconfig.json @@ -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"] +} diff --git a/apps/agent-demo/vite.config.ts b/apps/agent-demo/vite.config.ts new file mode 100644 index 0000000..a4a44aa --- /dev/null +++ b/apps/agent-demo/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { port: 5174 }, +}); diff --git a/apps/message-demo/package.json b/apps/message-demo/package.json index a9099ba..3cfbb26 100644 --- a/apps/message-demo/package.json +++ b/apps/message-demo/package.json @@ -11,6 +11,7 @@ "@insignia/iios-kernel-client": "workspace:*", "@insignia/iios-message-web": "workspace:*", "@insignia/iios-inbox-web": "workspace:*", + "@insignia/iios-support-web": "workspace:*", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/apps/message-demo/src/App.tsx b/apps/message-demo/src/App.tsx index 65569b6..a8291e9 100644 --- a/apps/message-demo/src/App.tsx +++ b/apps/message-demo/src/App.tsx @@ -1,6 +1,7 @@ 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 { SupportProvider, useEscalate } from '@insignia/iios-support-web'; const SERVICE = 'http://localhost:3200'; const APP_ID = 'portal-demo'; @@ -43,6 +44,7 @@ function ChatInner({ }, [threadId]); const { messages, send, typing, typingUsers, markRead, reads } = useMessages(tid); + const escalate = useEscalate(); return (
@@ -80,6 +82,15 @@ function ChatInner({ > mark last read +
); } @@ -109,9 +120,9 @@ function Pane(props: { token: string | null; label: string; threadId: string | n - + - + ); } diff --git a/packages/iios-service/scripts/seed-support.mjs b/packages/iios-service/scripts/seed-support.mjs new file mode 100644 index 0000000..df2b61c --- /dev/null +++ b/packages/iios-service/scripts/seed-support.mjs @@ -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); diff --git a/packages/iios-service/scripts/smoke-support.mjs b/packages/iios-service/scripts/smoke-support.mjs new file mode 100644 index 0000000..3730e2f --- /dev/null +++ b/packages/iios-service/scripts/smoke-support.mjs @@ -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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca6fa36..2683c87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,40 @@ importers: specifier: ^3.0.5 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: dependencies: '@insignia/iios-inbox-web': @@ -29,6 +63,9 @@ importers: '@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