From 9c6e2c76e8107750885030365354d35444291ffe Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 1 Jul 2026 12:39:14 +0530 Subject: [PATCH] fix(support): agent-demo self-seeds (join default queue + go online); tickets always get a queue Root cause of 'waiting for escalations': no queue/membership meant tickets were never assigned. Now: createTicket find-or-creates a default queue; new joinDefault endpoint + useGoOnline hook; agent-demo joins+goes-AVAILABLE on load (assignPending picks up any waiting ticket). smoke-support self-seeds (no seed script needed). Also: vitest singleFork to end cross-file DB races (45 tests deterministic). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/agent-demo/src/App.tsx | 6 +++--- packages/iios-kernel-client/src/rest.ts | 3 +++ packages/iios-service/scripts/smoke-support.mjs | 8 +++++++- packages/iios-service/src/support/assignment.service.ts | 9 +++++++++ packages/iios-service/src/support/support.controller.ts | 5 +++++ packages/iios-service/src/support/support.service.ts | 8 ++++++-- packages/iios-support-web/src/index.ts | 1 + packages/iios-support-web/src/react.tsx | 9 +++++++++ vitest.config.ts | 8 ++++++-- 9 files changed, 49 insertions(+), 8 deletions(-) diff --git a/apps/agent-demo/src/App.tsx b/apps/agent-demo/src/App.tsx index b871efc..e333d9f 100644 --- a/apps/agent-demo/src/App.tsx +++ b/apps/agent-demo/src/App.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { SupportProvider, useAssignedTickets, - useAvailability, + useGoOnline, useThread, useMessages, type Ticket, @@ -57,12 +57,12 @@ function AgentChat({ threadId }: { threadId: string }) { } function AgentInner() { - const setAvailability = useAvailability(); + const goOnline = useGoOnline(); const { tickets } = useAssignedTickets(); const [threadId, setThreadId] = useState(null); useEffect(() => { - void setAvailability('AVAILABLE'); + void goOnline(); // join default queue + go AVAILABLE // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/packages/iios-kernel-client/src/rest.ts b/packages/iios-kernel-client/src/rest.ts index 2562031..525c541 100644 --- a/packages/iios-kernel-client/src/rest.ts +++ b/packages/iios-kernel-client/src/rest.ts @@ -87,6 +87,9 @@ export class RestClient { async joinQueue(queueId: string): Promise { return this.post('/v1/support/queues/' + queueId + '/members', {}); } + async joinDefaultQueue(): Promise { + return this.post('/v1/support/agents/me/join', {}); + } async setAvailability(state: string): Promise { const r = await fetch(this.url('/v1/support/members/me/availability'), { method: 'PATCH', diff --git a/packages/iios-service/scripts/smoke-support.mjs b/packages/iios-service/scripts/smoke-support.mjs index 3730e2f..528fb48 100644 --- a/packages/iios-service/scripts/smoke-support.mjs +++ b/packages/iios-service/scripts/smoke-support.mjs @@ -36,10 +36,16 @@ function waitForMessage(socket, pred, ms = 6000) { } const custToken = sign('cust'); +const agentId = process.env.AGENT_ID ?? 'agent1'; +const agentToken = sign(agentId); const cust = connect('cust'); -const agent = connect(process.env.AGENT_ID ?? 'agent1'); +const agent = connect(agentId); try { + // Agent self-seeds exactly like the agent-demo does (join default queue + go online). + await api('/v1/support/agents/me/join', 'POST', agentToken); + await api('/v1/support/members/me/availability', 'PATCH', agentToken, { state: 'AVAILABLE' }); + const { threadId } = await cust.emitWithAck('open_thread', {}); await cust.emitWithAck('send_message', { threadId, content: 'my payment failed' }); diff --git a/packages/iios-service/src/support/assignment.service.ts b/packages/iios-service/src/support/assignment.service.ts index bbf313b..8eba174 100644 --- a/packages/iios-service/src/support/assignment.service.ts +++ b/packages/iios-service/src/support/assignment.service.ts @@ -111,6 +111,15 @@ export class AssignmentService implements OnModuleInit { return this.prisma.iiosSupportQueue.create({ data: { scopeId: scope.id, name } }); } + /** Find-or-create the scope's default queue and add the caller as a member. */ + async joinDefault(agent: MessagePrincipal) { + const scope = await this.actors.resolveScope(agent); + const queue = + (await this.prisma.iiosSupportQueue.findFirst({ where: { scopeId: scope.id, enabled: true } })) ?? + (await this.prisma.iiosSupportQueue.create({ data: { scopeId: scope.id, name: 'Support' } })); + return this.addMember(queue.id, agent); + } + async addMember(queueId: string, agent: MessagePrincipal, opts?: { maxActive?: number }) { const queue = await this.prisma.iiosSupportQueue.findUniqueOrThrow({ where: { id: queueId } }); const actor = await this.actors.resolveActor(queue.scopeId, agent); diff --git a/packages/iios-service/src/support/support.controller.ts b/packages/iios-service/src/support/support.controller.ts index 2cccfdb..f711e97 100644 --- a/packages/iios-service/src/support/support.controller.ts +++ b/packages/iios-service/src/support/support.controller.ts @@ -63,6 +63,11 @@ export class SupportController { return this.assignment.addMember(id, this.principal(auth)); } + @Post('agents/me/join') + async joinDefault(@Headers('authorization') auth?: string) { + return this.assignment.joinDefault(this.principal(auth)); + } + @Patch('members/me/availability') async availability(@Body() body: AvailabilityDto, @Headers('authorization') auth?: string) { return this.assignment.setAvailability(this.principal(auth), body.state); diff --git a/packages/iios-service/src/support/support.service.ts b/packages/iios-service/src/support/support.service.ts index dfd3ef1..a490d96 100644 --- a/packages/iios-service/src/support/support.service.ts +++ b/packages/iios-service/src/support/support.service.ts @@ -40,10 +40,14 @@ export class SupportService { const requester = await this.actors.resolveActor(scope.id, principal); const traceId = randomUUID(); - // Default to the scope's first enabled queue so assignment has candidates. + // Every ticket gets a queue so assignment has somewhere to route it — find + // the scope's default queue or create one. const queueId = input.queueId ?? - (await this.prisma.iiosSupportQueue.findFirst({ where: { scopeId: scope.id, enabled: true } }))?.id; + ( + (await this.prisma.iiosSupportQueue.findFirst({ where: { scopeId: scope.id, enabled: true } })) ?? + (await this.prisma.iiosSupportQueue.create({ data: { scopeId: scope.id, name: 'Support' } })) + ).id; return this.prisma.$transaction(async (tx) => { const ticket = await tx.iiosTicket.create({ diff --git a/packages/iios-support-web/src/index.ts b/packages/iios-support-web/src/index.ts index 101aac0..e6a57ea 100644 --- a/packages/iios-support-web/src/index.ts +++ b/packages/iios-support-web/src/index.ts @@ -5,6 +5,7 @@ export { useAssignedTickets, useCallbackRequest, useAvailability, + useGoOnline, } from './react'; // Re-export the chat hooks so a support app imports everything from one place. export { useThread, useMessages } from '@insignia/iios-message-web'; diff --git a/packages/iios-support-web/src/react.tsx b/packages/iios-support-web/src/react.tsx index 8768358..9538ddd 100644 --- a/packages/iios-support-web/src/react.tsx +++ b/packages/iios-support-web/src/react.tsx @@ -89,3 +89,12 @@ export function useAvailability(): (state: string) => Promise { const client = useSupportClient(); return (state) => client.setAvailability(state); } + +/** Agent onboarding: join the default queue + go AVAILABLE (self-seed). */ +export function useGoOnline(): () => Promise { + const client = useSupportClient(); + return async () => { + await client.joinDefaultQueue(); + await client.setAvailability('AVAILABLE'); + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index f780b06..8455613 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,8 +14,12 @@ export default defineConfig({ }, test: { include: ['packages/**/src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'], - // DB-backed specs share one Postgres and truncate tables between tests, so - // test files must not run in parallel against it. + // DB-backed specs share one Postgres and TRUNCATE between tests. Run every + // file in a single worker process, sequentially, so there is no cross-file + // race on the shared database. fileParallelism: false, + sequence: { concurrent: false }, + pool: 'forks', + poolOptions: { forks: { singleFork: true } }, }, });