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) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
SupportProvider,
|
SupportProvider,
|
||||||
useAssignedTickets,
|
useAssignedTickets,
|
||||||
useAvailability,
|
useGoOnline,
|
||||||
useThread,
|
useThread,
|
||||||
useMessages,
|
useMessages,
|
||||||
type Ticket,
|
type Ticket,
|
||||||
@@ -57,12 +57,12 @@ function AgentChat({ threadId }: { threadId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AgentInner() {
|
function AgentInner() {
|
||||||
const setAvailability = useAvailability();
|
const goOnline = useGoOnline();
|
||||||
const { tickets } = useAssignedTickets();
|
const { tickets } = useAssignedTickets();
|
||||||
const [threadId, setThreadId] = useState<string | null>(null);
|
const [threadId, setThreadId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void setAvailability('AVAILABLE');
|
void goOnline(); // join default queue + go AVAILABLE
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ export class RestClient {
|
|||||||
async joinQueue(queueId: string): Promise<unknown> {
|
async joinQueue(queueId: string): Promise<unknown> {
|
||||||
return this.post('/v1/support/queues/' + queueId + '/members', {});
|
return this.post('/v1/support/queues/' + queueId + '/members', {});
|
||||||
}
|
}
|
||||||
|
async joinDefaultQueue(): Promise<unknown> {
|
||||||
|
return this.post('/v1/support/agents/me/join', {});
|
||||||
|
}
|
||||||
async setAvailability(state: string): Promise<unknown> {
|
async setAvailability(state: string): Promise<unknown> {
|
||||||
const r = await fetch(this.url('/v1/support/members/me/availability'), {
|
const r = await fetch(this.url('/v1/support/members/me/availability'), {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@@ -36,10 +36,16 @@ function waitForMessage(socket, pred, ms = 6000) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const custToken = sign('cust');
|
const custToken = sign('cust');
|
||||||
|
const agentId = process.env.AGENT_ID ?? 'agent1';
|
||||||
|
const agentToken = sign(agentId);
|
||||||
const cust = connect('cust');
|
const cust = connect('cust');
|
||||||
const agent = connect(process.env.AGENT_ID ?? 'agent1');
|
const agent = connect(agentId);
|
||||||
|
|
||||||
try {
|
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', {});
|
const { threadId } = await cust.emitWithAck('open_thread', {});
|
||||||
await cust.emitWithAck('send_message', { threadId, content: 'my payment failed' });
|
await cust.emitWithAck('send_message', { threadId, content: 'my payment failed' });
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,15 @@ export class AssignmentService implements OnModuleInit {
|
|||||||
return this.prisma.iiosSupportQueue.create({ data: { scopeId: scope.id, name } });
|
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 }) {
|
async addMember(queueId: string, agent: MessagePrincipal, opts?: { maxActive?: number }) {
|
||||||
const queue = await this.prisma.iiosSupportQueue.findUniqueOrThrow({ where: { id: queueId } });
|
const queue = await this.prisma.iiosSupportQueue.findUniqueOrThrow({ where: { id: queueId } });
|
||||||
const actor = await this.actors.resolveActor(queue.scopeId, agent);
|
const actor = await this.actors.resolveActor(queue.scopeId, agent);
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export class SupportController {
|
|||||||
return this.assignment.addMember(id, this.principal(auth));
|
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')
|
@Patch('members/me/availability')
|
||||||
async availability(@Body() body: AvailabilityDto, @Headers('authorization') auth?: string) {
|
async availability(@Body() body: AvailabilityDto, @Headers('authorization') auth?: string) {
|
||||||
return this.assignment.setAvailability(this.principal(auth), body.state);
|
return this.assignment.setAvailability(this.principal(auth), body.state);
|
||||||
|
|||||||
@@ -40,10 +40,14 @@ export class SupportService {
|
|||||||
const requester = await this.actors.resolveActor(scope.id, principal);
|
const requester = await this.actors.resolveActor(scope.id, principal);
|
||||||
const traceId = randomUUID();
|
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 =
|
const queueId =
|
||||||
input.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) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const ticket = await tx.iiosTicket.create({
|
const ticket = await tx.iiosTicket.create({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
useAssignedTickets,
|
useAssignedTickets,
|
||||||
useCallbackRequest,
|
useCallbackRequest,
|
||||||
useAvailability,
|
useAvailability,
|
||||||
|
useGoOnline,
|
||||||
} from './react';
|
} from './react';
|
||||||
// Re-export the chat hooks so a support app imports everything from one place.
|
// Re-export the chat hooks so a support app imports everything from one place.
|
||||||
export { useThread, useMessages } from '@insignia/iios-message-web';
|
export { useThread, useMessages } from '@insignia/iios-message-web';
|
||||||
|
|||||||
@@ -89,3 +89,12 @@ export function useAvailability(): (state: string) => Promise<unknown> {
|
|||||||
const client = useSupportClient();
|
const client = useSupportClient();
|
||||||
return (state) => client.setAvailability(state);
|
return (state) => client.setAvailability(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Agent onboarding: join the default queue + go AVAILABLE (self-seed). */
|
||||||
|
export function useGoOnline(): () => Promise<void> {
|
||||||
|
const client = useSupportClient();
|
||||||
|
return async () => {
|
||||||
|
await client.joinDefaultQueue();
|
||||||
|
await client.setAvailability('AVAILABLE');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+6
-2
@@ -14,8 +14,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ['packages/**/src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'],
|
include: ['packages/**/src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'],
|
||||||
// DB-backed specs share one Postgres and truncate tables between tests, so
|
// DB-backed specs share one Postgres and TRUNCATE between tests. Run every
|
||||||
// test files must not run in parallel against it.
|
// file in a single worker process, sequentially, so there is no cross-file
|
||||||
|
// race on the shared database.
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
|
sequence: { concurrent: false },
|
||||||
|
pool: 'forks',
|
||||||
|
poolOptions: { forks: { singleFork: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user